feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집
이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다. 자막 on/off - 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey + 네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op - 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합 모니터 선택 + 디스코드식 배치 - placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표) - AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환 - 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프 GPU 저부하 모드 (기본 켜짐) - 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보, 300초 무음 시 모델 언로드, 중간 결과 비활성 - 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향 - 8GB GPU 기준 자막 2.8GB / 게임 5.2GB 게임 용어집 번들 (304개) - 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38 - 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선 검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
6
src/livesub/__init__.py
Normal file
6
src/livesub/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""LiveSub — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구."""
|
||||
|
||||
from .constants import APP_NAME, APP_SLOGAN, APP_VERSION
|
||||
|
||||
__all__ = ["APP_NAME", "APP_SLOGAN", "APP_VERSION", "__version__"]
|
||||
__version__ = APP_VERSION
|
||||
4
src/livesub/__main__.py
Normal file
4
src/livesub/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
53
src/livesub/app.py
Normal file
53
src/livesub/app.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""애플리케이션 진입점."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from .config import AppConfig
|
||||
from .constants import APP_NAME, ORG_NAME, user_data_dir
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False) -> None:
|
||||
log_file = user_data_dir() / "livesub.log"
|
||||
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
||||
with contextlib.suppress(OSError):
|
||||
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
handlers=handlers,
|
||||
force=True,
|
||||
)
|
||||
# 모델 라이브러리들이 INFO로 너무 시끄럽다.
|
||||
for noisy in ("urllib3", "filelock", "huggingface_hub", "transformers"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv if argv is None else argv)
|
||||
verbose = "--verbose" in argv or "-v" in argv
|
||||
setup_logging(verbose)
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .ui import MainWindow
|
||||
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||
app = QApplication(argv)
|
||||
app.setApplicationName(APP_NAME)
|
||||
app.setOrganizationName(ORG_NAME)
|
||||
app.setQuitOnLastWindowClosed(True)
|
||||
|
||||
config = AppConfig.load()
|
||||
window = MainWindow(config)
|
||||
if not config.start_minimized:
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
72
src/livesub/audio/__init__.py
Normal file
72
src/livesub/audio/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""오디오 캡처 레이어."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..config import AudioConfig
|
||||
from .base import AudioSource, CaptureBackend, CaptureError
|
||||
from .file_source import FileCapture
|
||||
from .process_loopback import ProcessLoopbackCapture
|
||||
from .segmenter import Segment, Segmenter, SegmenterConfig
|
||||
from .wasapi_loopback import WasapiLoopbackCapture
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"AudioSource",
|
||||
"CaptureBackend",
|
||||
"CaptureError",
|
||||
"FileCapture",
|
||||
"ProcessLoopbackCapture",
|
||||
"Segment",
|
||||
"Segmenter",
|
||||
"SegmenterConfig",
|
||||
"WasapiLoopbackCapture",
|
||||
"capture_capabilities",
|
||||
"create_capture",
|
||||
"list_sources",
|
||||
]
|
||||
|
||||
|
||||
def capture_capabilities() -> dict[str, bool]:
|
||||
"""현재 환경에서 어떤 캡처가 가능한지."""
|
||||
return {
|
||||
"process": ProcessLoopbackCapture.available(),
|
||||
"device": WasapiLoopbackCapture.available(),
|
||||
"file": True,
|
||||
}
|
||||
|
||||
|
||||
def list_sources() -> list[AudioSource]:
|
||||
"""프로그램 + 출력 장치를 한 목록으로."""
|
||||
sources: list[AudioSource] = []
|
||||
if ProcessLoopbackCapture.available():
|
||||
sources.extend(ProcessLoopbackCapture.list_sources())
|
||||
sources.extend(WasapiLoopbackCapture.list_sources())
|
||||
return sources
|
||||
|
||||
|
||||
def create_capture(config: AudioConfig) -> CaptureBackend:
|
||||
"""설정에 맞는 캡처 백엔드를 만든다. 불가능하면 장치 루프백으로 폴백."""
|
||||
backend = config.backend
|
||||
if backend == "file":
|
||||
return FileCapture(config.target_process_name)
|
||||
|
||||
want_process = backend in ("process", "auto") and config.target_pid > 0
|
||||
if want_process:
|
||||
if ProcessLoopbackCapture.available():
|
||||
return ProcessLoopbackCapture(config.target_pid)
|
||||
if backend == "process":
|
||||
raise CaptureError(
|
||||
"프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
|
||||
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
|
||||
)
|
||||
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")
|
||||
|
||||
if not WasapiLoopbackCapture.available():
|
||||
raise CaptureError(
|
||||
"이 환경에서는 오디오 캡처를 할 수 없습니다. "
|
||||
"Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
|
||||
)
|
||||
return WasapiLoopbackCapture(config.device_index)
|
||||
146
src/livesub/audio/base.py
Normal file
146
src/livesub/audio/base.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""오디오 캡처 백엔드 공통 인터페이스.
|
||||
|
||||
모든 백엔드는 16kHz / mono / float32(-1.0~1.0) 프레임을 큐로 흘려보낸다.
|
||||
리샘플링과 다운믹스는 각 백엔드가 책임진다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import SAMPLE_RATE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioSource:
|
||||
"""UI에 노출되는 캡처 대상 한 줄."""
|
||||
|
||||
kind: str # "process" | "device"
|
||||
identifier: str # pid 문자열 또는 장치 인덱스
|
||||
label: str
|
||||
detail: str = ""
|
||||
icon_path: str = ""
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return int(self.identifier) if self.kind == "process" else 0
|
||||
|
||||
@property
|
||||
def device_index(self) -> int:
|
||||
return int(self.identifier) if self.kind == "device" else -1
|
||||
|
||||
|
||||
class CaptureError(RuntimeError):
|
||||
"""캡처를 시작할 수 없을 때."""
|
||||
|
||||
|
||||
class CaptureBackend(abc.ABC):
|
||||
"""오디오 캡처 백엔드 베이스."""
|
||||
|
||||
name = "base"
|
||||
#: 특정 프로그램 소리만 분리해서 받을 수 있는가
|
||||
per_process = False
|
||||
|
||||
def __init__(self, max_queue_frames: int = 400) -> None:
|
||||
self._queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=max_queue_frames)
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._error: Exception | None = None
|
||||
|
||||
# --- 하위 클래스가 구현 -------------------------------------------
|
||||
@abc.abstractmethod
|
||||
def _run(self) -> None:
|
||||
"""블로킹 캡처 루프. self._stop 이 set 될 때까지 _emit() 호출."""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def available() -> bool:
|
||||
"""현재 환경에서 이 백엔드를 쓸 수 있는가."""
|
||||
|
||||
@staticmethod
|
||||
@abc.abstractmethod
|
||||
def list_sources() -> list[AudioSource]:
|
||||
"""선택 가능한 캡처 대상 목록."""
|
||||
|
||||
# --- 공통 동작 -----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._error = None
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main, name=f"capture-{self.name}", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
try:
|
||||
self._run()
|
||||
except Exception as exc: # noqa: BLE001 - 워커 스레드 경계
|
||||
self._error = exc
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self._stop.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return bool(self._thread and self._thread.is_alive())
|
||||
|
||||
@property
|
||||
def error(self) -> Exception | None:
|
||||
return self._error
|
||||
|
||||
def _emit(self, frame: np.ndarray) -> None:
|
||||
"""캡처 프레임 투입. 큐가 가득 차면 가장 오래된 프레임을 버린다.
|
||||
|
||||
실시간 자막에서는 밀린 오디오보다 최신 오디오가 항상 더 가치 있다.
|
||||
"""
|
||||
try:
|
||||
self._queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.put_nowait(frame)
|
||||
except (queue.Empty, queue.Full):
|
||||
pass
|
||||
|
||||
def read(self, timeout: float = 0.5) -> np.ndarray | None:
|
||||
try:
|
||||
return self._queue.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
def drain(self) -> None:
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def to_mono_16k(data: np.ndarray, channels: int, src_rate: int) -> np.ndarray:
|
||||
"""인터리브된 float32 PCM을 16kHz 모노로 변환."""
|
||||
if data.size == 0:
|
||||
return data.astype(np.float32, copy=False)
|
||||
audio = data.astype(np.float32, copy=False)
|
||||
if channels > 1:
|
||||
usable = (audio.size // channels) * channels
|
||||
audio = audio[:usable].reshape(-1, channels).mean(axis=1)
|
||||
if src_rate != SAMPLE_RATE and audio.size:
|
||||
# 선형 보간 리샘플. 음성인식 입력으로는 충분한 품질이며 의존성이 없다.
|
||||
duration = audio.size / src_rate
|
||||
target_len = max(1, int(duration * SAMPLE_RATE))
|
||||
audio = np.interp(
|
||||
np.linspace(0.0, audio.size - 1, target_len, dtype=np.float64),
|
||||
np.arange(audio.size, dtype=np.float64),
|
||||
audio,
|
||||
).astype(np.float32)
|
||||
return audio
|
||||
66
src/livesub/audio/file_source.py
Normal file
66
src/livesub/audio/file_source.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""파일/합성 오디오 캡처 백엔드.
|
||||
|
||||
실제 오디오 장치 없이 파이프라인 전체를 돌려볼 수 있게 해준다.
|
||||
개발·테스트·데모용이며 어느 OS에서나 동작한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import FRAME_SAMPLES, SAMPLE_RATE
|
||||
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
|
||||
|
||||
|
||||
class FileCapture(CaptureBackend):
|
||||
"""WAV 파일을 실시간 속도로 재생하듯 흘려보낸다."""
|
||||
|
||||
name = "file"
|
||||
per_process = False
|
||||
|
||||
def __init__(self, path: str | Path, loop: bool = False, realtime: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.path = Path(path)
|
||||
self.loop = loop
|
||||
self.realtime = realtime
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def list_sources() -> list[AudioSource]:
|
||||
return []
|
||||
|
||||
def _read_wav(self) -> np.ndarray:
|
||||
if not self.path.is_file():
|
||||
raise CaptureError(f"오디오 파일을 찾을 수 없습니다: {self.path}")
|
||||
with wave.open(str(self.path), "rb") as wf:
|
||||
channels = wf.getnchannels()
|
||||
rate = wf.getframerate()
|
||||
width = wf.getsampwidth()
|
||||
raw = wf.readframes(wf.getnframes())
|
||||
if width == 2:
|
||||
data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
elif width == 4:
|
||||
data = np.frombuffer(raw, dtype=np.float32)
|
||||
else:
|
||||
raise CaptureError(f"지원하지 않는 샘플 폭입니다: {width * 8}bit")
|
||||
return to_mono_16k(data, channels, rate)
|
||||
|
||||
def _run(self) -> None:
|
||||
audio = self._read_wav()
|
||||
chunk = FRAME_SAMPLES * 5 # 100ms
|
||||
while not self._stop.is_set():
|
||||
for start in range(0, audio.size, chunk):
|
||||
if self._stop.is_set():
|
||||
return
|
||||
self._emit(audio[start : start + chunk].copy())
|
||||
if self.realtime:
|
||||
time.sleep(chunk / SAMPLE_RATE)
|
||||
if not self.loop:
|
||||
return
|
||||
168
src/livesub/audio/process_loopback.py
Normal file
168
src/livesub/audio/process_loopback.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""프로그램별 오디오 캡처 (Windows WASAPI process loopback).
|
||||
|
||||
`native/process_loopback` 에서 빌드한 `livesub_capture.exe` 를 자식 프로세스로 띄우고
|
||||
stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다.
|
||||
|
||||
보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는
|
||||
장치 루프백으로 자동 폴백한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import FRAME_SAMPLES
|
||||
from .base import AudioSource, CaptureBackend, CaptureError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HELPER_NAME = "livesub_capture.exe"
|
||||
READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량
|
||||
|
||||
|
||||
def helper_path() -> Path | None:
|
||||
"""보조 프로그램 위치. 패키지 내부 → 저장소 빌드 폴더 순으로 찾는다."""
|
||||
candidates = [
|
||||
Path(__file__).resolve().parent.parent / "resources" / "bin" / HELPER_NAME,
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "native"
|
||||
/ "process_loopback"
|
||||
/ "build"
|
||||
/ "Release"
|
||||
/ HELPER_NAME,
|
||||
]
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
class ProcessLoopbackCapture(CaptureBackend):
|
||||
name = "process"
|
||||
per_process = True
|
||||
|
||||
def __init__(self, pid: int, exclude: bool = False) -> None:
|
||||
super().__init__()
|
||||
self.pid = pid
|
||||
self.exclude = exclude
|
||||
self._proc: subprocess.Popen[bytes] | None = None
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
return sys.platform == "win32" and helper_path() is not None
|
||||
|
||||
@staticmethod
|
||||
def list_sources() -> list[AudioSource]:
|
||||
"""현재 소리를 내고 있는(또는 낼 수 있는) 프로세스 목록."""
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
try:
|
||||
from pycaw.pycaw import AudioUtilities
|
||||
except ImportError:
|
||||
log.debug("pycaw 미설치 — 프로세스 목록을 만들 수 없습니다.")
|
||||
return []
|
||||
|
||||
seen: dict[int, AudioSource] = {}
|
||||
for session in AudioUtilities.GetAllSessions():
|
||||
proc = session.Process
|
||||
if proc is None or proc.pid in seen:
|
||||
continue
|
||||
try:
|
||||
name = proc.name()
|
||||
except Exception: # noqa: BLE001 - 종료된 프로세스
|
||||
continue
|
||||
title = _window_title(proc.pid) or name
|
||||
seen[proc.pid] = AudioSource(
|
||||
kind="process",
|
||||
identifier=str(proc.pid),
|
||||
label=title,
|
||||
detail=f"{name} (PID {proc.pid})",
|
||||
)
|
||||
return sorted(seen.values(), key=lambda s: s.label.lower())
|
||||
|
||||
def _run(self) -> None: # pragma: no cover - Windows 전용
|
||||
exe = helper_path()
|
||||
if exe is None:
|
||||
raise CaptureError(
|
||||
f"{HELPER_NAME} 를 찾지 못했습니다. "
|
||||
"native/process_loopback/build.ps1 로 먼저 빌드하세요."
|
||||
)
|
||||
cmd = [str(exe), "--pid", str(self.pid)]
|
||||
if self.exclude:
|
||||
cmd.append("--exclude")
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
assert self._proc.stdout is not None
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
raw = self._proc.stdout.read(READ_BYTES)
|
||||
if not raw:
|
||||
code = self._proc.poll()
|
||||
err = b""
|
||||
if self._proc.stderr is not None:
|
||||
err = self._proc.stderr.read() or b""
|
||||
raise CaptureError(
|
||||
f"캡처 보조 프로그램이 종료되었습니다(코드 {code}): "
|
||||
f"{err.decode('utf-8', 'replace').strip()}"
|
||||
)
|
||||
# 4바이트 정렬이 깨지면 float32 해석이 밀린다.
|
||||
usable = len(raw) - (len(raw) % 4)
|
||||
self._emit(np.frombuffer(raw[:usable], dtype=np.float32).copy())
|
||||
finally:
|
||||
self._terminate()
|
||||
|
||||
def _terminate(self) -> None:
|
||||
proc, self._proc = self._proc, None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
for pipe in (proc.stdout, proc.stderr):
|
||||
if pipe is not None:
|
||||
pipe.close()
|
||||
|
||||
|
||||
def _window_title(pid: int) -> str:
|
||||
"""해당 PID의 최상위 창 제목. 실패하면 빈 문자열."""
|
||||
if sys.platform != "win32":
|
||||
return ""
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
result: list[str] = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def callback(hwnd, _lparam):
|
||||
owner = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
||||
if owner.value != pid or not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
if length <= 0:
|
||||
return True
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
result.append(buf.value)
|
||||
return False
|
||||
|
||||
user32.EnumWindows(callback, 0)
|
||||
return result[0] if result else ""
|
||||
except Exception: # noqa: BLE001 - 창 제목은 부가 정보일 뿐
|
||||
return ""
|
||||
188
src/livesub/audio/segmenter.py
Normal file
188
src/livesub/audio/segmenter.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""발화 구간 분할기.
|
||||
|
||||
캡처된 연속 오디오를 "한 문장" 단위로 잘라 음성인식에 넘긴다.
|
||||
외부 의존성 없이 동작하도록 적응형 노이즈 플로어 기반 에너지 VAD를 쓴다.
|
||||
webrtcvad 가 설치돼 있으면 더 정확한 판정을 위해 함께 사용한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import FRAME_MS, FRAME_SAMPLES, SAMPLE_RATE
|
||||
|
||||
try: # pragma: no cover - 선택 의존성
|
||||
import webrtcvad
|
||||
except ImportError: # pragma: no cover
|
||||
webrtcvad = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""음성인식에 넘길 오디오 한 덩어리."""
|
||||
|
||||
audio: np.ndarray
|
||||
started_at: float
|
||||
ended_at: float
|
||||
is_final: bool = True
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
return self.audio.size / SAMPLE_RATE
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmenterConfig:
|
||||
silence_ms: int = 600
|
||||
min_segment_ms: int = 400
|
||||
max_segment_ms: int = 12_000
|
||||
partial_interval_ms: int = 900
|
||||
#: 노이즈 플로어 대비 몇 배 이상이면 발화로 볼지
|
||||
speech_ratio: float = 3.0
|
||||
#: 절대 무음 판정 하한 (RMS)
|
||||
absolute_floor: float = 2e-4
|
||||
vad_aggressiveness: int = 2
|
||||
#: 발화 시작 앞쪽에 붙일 여유분 (앞 음절 잘림 방지)
|
||||
lead_in_ms: int = 240
|
||||
|
||||
|
||||
class Segmenter:
|
||||
"""프레임을 받아 발화 구간을 뱉는 상태 기계.
|
||||
|
||||
`push()` 는 확정 구간(is_final=True) 또는 중간 결과(is_final=False)를
|
||||
돌려주거나, 아직 내보낼 게 없으면 None 을 돌려준다.
|
||||
"""
|
||||
|
||||
def __init__(self, config: SegmenterConfig | None = None) -> None:
|
||||
self.config = config or SegmenterConfig()
|
||||
self._vad = None
|
||||
if webrtcvad is not None:
|
||||
self._vad = webrtcvad.Vad(max(0, min(3, self.config.vad_aggressiveness)))
|
||||
|
||||
self._buffer: list[np.ndarray] = []
|
||||
self._lead_in: list[np.ndarray] = []
|
||||
self._pending = np.zeros(0, dtype=np.float32)
|
||||
self._noise_floor = 1e-3
|
||||
self._silence_ms = 0
|
||||
self._speech_ms = 0
|
||||
self._in_speech = False
|
||||
self._started_at = 0.0
|
||||
self._last_partial_ms = 0
|
||||
self._lead_in_frames = max(1, self.config.lead_in_ms // FRAME_MS)
|
||||
|
||||
# --- 입력 ----------------------------------------------------------
|
||||
def push(self, chunk: np.ndarray) -> list[Segment]:
|
||||
"""임의 길이의 오디오를 넣고 완성된 구간 목록을 받는다."""
|
||||
out: list[Segment] = []
|
||||
if chunk.size:
|
||||
self._pending = (
|
||||
chunk.astype(np.float32, copy=False)
|
||||
if self._pending.size == 0
|
||||
else np.concatenate((self._pending, chunk))
|
||||
)
|
||||
while self._pending.size >= FRAME_SAMPLES:
|
||||
frame = self._pending[:FRAME_SAMPLES]
|
||||
self._pending = self._pending[FRAME_SAMPLES:]
|
||||
segment = self._push_frame(frame)
|
||||
if segment is not None:
|
||||
out.append(segment)
|
||||
return out
|
||||
|
||||
def flush(self) -> Segment | None:
|
||||
"""남은 버퍼를 강제로 확정 구간으로 만든다 (캡처 종료 시)."""
|
||||
if not self._in_speech:
|
||||
return None
|
||||
return self._finish()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""발화 상태만 초기화한다.
|
||||
|
||||
`_pending` 은 건드리지 않는다. 아직 프레임으로 쪼개지 못한 '입력' 이라
|
||||
여기서 버리면 한 구간을 확정한 직후의 오디오가 통째로 사라진다.
|
||||
"""
|
||||
self._buffer.clear()
|
||||
self._lead_in.clear()
|
||||
self._silence_ms = 0
|
||||
self._speech_ms = 0
|
||||
self._in_speech = False
|
||||
self._last_partial_ms = 0
|
||||
|
||||
# --- 내부 ----------------------------------------------------------
|
||||
def _push_frame(self, frame: np.ndarray) -> Segment | None:
|
||||
speech = self._is_speech(frame)
|
||||
cfg = self.config
|
||||
|
||||
if not self._in_speech:
|
||||
self._lead_in.append(frame)
|
||||
if len(self._lead_in) > self._lead_in_frames:
|
||||
self._lead_in.pop(0)
|
||||
if speech:
|
||||
self._in_speech = True
|
||||
self._started_at = time.monotonic()
|
||||
self._buffer = list(self._lead_in)
|
||||
self._lead_in = []
|
||||
self._silence_ms = 0
|
||||
self._speech_ms = FRAME_MS
|
||||
self._last_partial_ms = 0
|
||||
return None
|
||||
|
||||
self._buffer.append(frame)
|
||||
self._speech_ms += FRAME_MS
|
||||
self._silence_ms = 0 if speech else self._silence_ms + FRAME_MS
|
||||
|
||||
buffered_ms = len(self._buffer) * FRAME_MS
|
||||
if self._silence_ms >= cfg.silence_ms:
|
||||
if buffered_ms - self._silence_ms < cfg.min_segment_ms:
|
||||
self.reset() # 잡음 한 번 튄 것 — 버린다
|
||||
return None
|
||||
return self._finish()
|
||||
|
||||
if buffered_ms >= cfg.max_segment_ms:
|
||||
return self._finish()
|
||||
|
||||
if cfg.partial_interval_ms > 0:
|
||||
since = buffered_ms - self._last_partial_ms
|
||||
if since >= cfg.partial_interval_ms and buffered_ms >= cfg.min_segment_ms:
|
||||
self._last_partial_ms = buffered_ms
|
||||
return Segment(
|
||||
audio=np.concatenate(self._buffer),
|
||||
started_at=self._started_at,
|
||||
ended_at=time.monotonic(),
|
||||
is_final=False,
|
||||
)
|
||||
return None
|
||||
|
||||
def _finish(self) -> Segment:
|
||||
audio = np.concatenate(self._buffer) if self._buffer else np.zeros(0, np.float32)
|
||||
segment = Segment(
|
||||
audio=audio,
|
||||
started_at=self._started_at,
|
||||
ended_at=time.monotonic(),
|
||||
is_final=True,
|
||||
)
|
||||
self.reset()
|
||||
return segment
|
||||
|
||||
def _is_speech(self, frame: np.ndarray) -> bool:
|
||||
rms = float(np.sqrt(np.mean(np.square(frame), dtype=np.float64)))
|
||||
cfg = self.config
|
||||
|
||||
if rms < cfg.absolute_floor:
|
||||
self._noise_floor = min(self._noise_floor, max(rms, 1e-6))
|
||||
return False
|
||||
|
||||
energetic = rms > max(self._noise_floor * cfg.speech_ratio, cfg.absolute_floor)
|
||||
if not energetic:
|
||||
# 무음 구간에서만 노이즈 플로어를 천천히 따라가게 한다.
|
||||
self._noise_floor = 0.95 * self._noise_floor + 0.05 * rms
|
||||
|
||||
if self._vad is None:
|
||||
return energetic
|
||||
try:
|
||||
pcm16 = (np.clip(frame, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
|
||||
return energetic and self._vad.is_speech(pcm16, SAMPLE_RATE)
|
||||
except Exception: # noqa: BLE001 - webrtcvad 는 프레임 길이에 민감
|
||||
return energetic
|
||||
95
src/livesub/audio/wasapi_loopback.py
Normal file
95
src/livesub/audio/wasapi_loopback.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""WASAPI 장치 루프백 캡처 (Windows).
|
||||
|
||||
출력 장치 전체의 소리를 받는다. 프로그램별 분리는 안 되지만 의존성이
|
||||
`PyAudioWPatch` 하나뿐이라 어디서나 바로 동작하는 기본 백엔드다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..constants import FRAME_SAMPLES
|
||||
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try: # pragma: no cover - 플랫폼 의존
|
||||
import pyaudiowpatch as pyaudio
|
||||
except ImportError: # pragma: no cover
|
||||
pyaudio = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class WasapiLoopbackCapture(CaptureBackend):
|
||||
name = "device"
|
||||
per_process = False
|
||||
|
||||
def __init__(self, device_index: int = -1) -> None:
|
||||
super().__init__()
|
||||
self.device_index = device_index
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
return pyaudio is not None
|
||||
|
||||
@staticmethod
|
||||
def list_sources() -> list[AudioSource]:
|
||||
if pyaudio is None:
|
||||
return []
|
||||
sources: list[AudioSource] = []
|
||||
pa = pyaudio.PyAudio()
|
||||
try:
|
||||
default_index = -1
|
||||
with contextlib.suppress(OSError, LookupError):
|
||||
default_index = pa.get_default_wasapi_loopback()["index"]
|
||||
for info in pa.get_loopback_device_info_generator():
|
||||
idx = int(info["index"])
|
||||
sources.append(
|
||||
AudioSource(
|
||||
kind="device",
|
||||
identifier=str(idx),
|
||||
label=str(info["name"]).replace(" [Loopback]", ""),
|
||||
detail="기본 출력 장치" if idx == default_index else "출력 장치",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
pa.terminate()
|
||||
return sources
|
||||
|
||||
def _resolve_device(self, pa) -> dict:
|
||||
if self.device_index >= 0:
|
||||
return pa.get_device_info_by_index(self.device_index)
|
||||
return pa.get_default_wasapi_loopback()
|
||||
|
||||
def _run(self) -> None: # pragma: no cover - 실제 오디오 장치 필요
|
||||
if pyaudio is None:
|
||||
raise CaptureError("PyAudioWPatch가 설치되지 않았습니다.")
|
||||
pa = pyaudio.PyAudio()
|
||||
stream = None
|
||||
try:
|
||||
info = self._resolve_device(pa)
|
||||
if info is None:
|
||||
raise CaptureError("루프백 장치를 찾지 못했습니다.")
|
||||
rate = int(info["defaultSampleRate"])
|
||||
channels = int(info["maxInputChannels"]) or 2
|
||||
chunk = max(FRAME_SAMPLES, int(rate * 0.02))
|
||||
stream = pa.open(
|
||||
format=pyaudio.paFloat32,
|
||||
channels=channels,
|
||||
rate=rate,
|
||||
input=True,
|
||||
input_device_index=int(info["index"]),
|
||||
frames_per_buffer=chunk,
|
||||
)
|
||||
log.info("장치 루프백 캡처 시작: %s (%dHz, %dch)", info["name"], rate, channels)
|
||||
while not self._stop.is_set():
|
||||
raw = stream.read(chunk, exception_on_overflow=False)
|
||||
data = np.frombuffer(raw, dtype=np.float32)
|
||||
self._emit(to_mono_16k(data, channels, rate))
|
||||
finally:
|
||||
if stream is not None:
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
pa.terminate()
|
||||
184
src/livesub/config.py
Normal file
184
src/livesub/config.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""설정 모델과 JSON 영속화."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .constants import DEFAULT_TIER, user_data_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = user_data_dir() / "config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleStyle:
|
||||
"""자막 텍스트 표현 설정."""
|
||||
|
||||
font_family: str = "Pretendard"
|
||||
font_size: int = 34
|
||||
bold: bool = True
|
||||
text_color: str = "#FFFFFF"
|
||||
source_text_color: str = "#9AA4B2"
|
||||
outline_color: str = "#000000"
|
||||
outline_width: int = 3
|
||||
background_color: str = "#000000"
|
||||
background_opacity: int = 55 # 0-100
|
||||
line_spacing: int = 6
|
||||
max_lines: int = 2
|
||||
show_source: bool = False # 원문 동시 표시
|
||||
align: str = "center" # left | center | right
|
||||
fade_out_ms: int = 4000 # 0이면 계속 유지
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverlayConfig:
|
||||
"""자막 창(오버레이) 위치/동작."""
|
||||
|
||||
x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
|
||||
y: int = -1
|
||||
width: int = 1100
|
||||
height: int = 200
|
||||
always_on_top: bool = True
|
||||
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
|
||||
click_through: bool = True
|
||||
locked: bool = False
|
||||
#: 자막 표시 on/off (단축키로 토글)
|
||||
visible: bool = True
|
||||
#: 자막을 띄울 모니터 인덱스 (0부터)
|
||||
screen_index: int = 0
|
||||
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
|
||||
anchor: str = "bottom-center"
|
||||
#: 화면 가장자리에서 띄울 간격
|
||||
margin: int = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
backend: str = "auto" # auto | process | device | file
|
||||
target_pid: int = 0
|
||||
target_process_name: str = ""
|
||||
device_index: int = -1 # -1 = 기본 출력 장치 루프백
|
||||
input_gain: float = 1.0
|
||||
vad_aggressiveness: int = 2 # 0-3
|
||||
silence_ms: int = 600 # 이 시간만큼 조용하면 한 문장 종료
|
||||
min_segment_ms: int = 400
|
||||
max_segment_ms: int = 12_000
|
||||
partial_interval_ms: int = 900 # 중간 결과 갱신 주기, 0이면 끔
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
tier: str = DEFAULT_TIER
|
||||
device: str = "cuda" # cuda | cpu
|
||||
device_index: int = 0
|
||||
source_lang: str = "auto" # auto 또는 언어코드
|
||||
target_lang: str = "ko"
|
||||
preload_on_start: bool = True
|
||||
lora_adapter_path: str = "" # 추가학습 어댑터 (비어있으면 미사용)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlossaryConfig:
|
||||
enabled: bool = True
|
||||
path: str = "" # 비어있으면 user_data_dir()/glossary.json
|
||||
case_sensitive: bool = False
|
||||
#: 함께 불러온 기본 제공 용어집 팩 키 목록 (presets/games 참고)
|
||||
enabled_packs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceConfig:
|
||||
"""게임과 동시에 돌릴 때 GPU를 얼마나 양보할지.
|
||||
|
||||
이 프로그램은 게임 위에 얹혀 돌아가는 것이 기본 용도라, 자막 품질보다
|
||||
게임 프레임을 지키는 쪽이 대체로 더 중요하다.
|
||||
"""
|
||||
|
||||
#: 저부하 모드. 켜면 아래 항목들이 자동으로 보수적인 값으로 적용된다.
|
||||
game_mode: bool = True
|
||||
#: 프로세스가 쓸 수 있는 VRAM 상한 비율 (0이면 제한 없음).
|
||||
#: 게임이 VRAM 부족으로 버벅이는 것을 막는다.
|
||||
vram_limit_ratio: float = 0.35
|
||||
#: 한 번 추론하고 나서 GPU를 놓아주는 시간. 게임 프레임 확보용.
|
||||
yield_ms: int = 60
|
||||
#: 이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납한다. 0이면 끔.
|
||||
idle_unload_s: int = 300
|
||||
#: 저부하 모드에서 연산 정밀도를 int8로 낮출지
|
||||
force_int8: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotkeyConfig:
|
||||
"""전역 단축키. 게임 창이 떠 있어도 동작한다."""
|
||||
|
||||
enabled: bool = True
|
||||
#: 자막 표시 on/off
|
||||
toggle_subtitle: str = "Ctrl+Alt+S"
|
||||
#: 번역 엔진 시작/정지
|
||||
toggle_engine: str = "Ctrl+Alt+D"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
audio: AudioConfig = field(default_factory=AudioConfig)
|
||||
models: ModelConfig = field(default_factory=ModelConfig)
|
||||
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
|
||||
overlay: OverlayConfig = field(default_factory=OverlayConfig)
|
||||
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
|
||||
performance: PerformanceConfig = field(default_factory=PerformanceConfig)
|
||||
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
|
||||
theme: str = "dark" # dark | light
|
||||
start_minimized: bool = False
|
||||
log_transcripts: bool = True
|
||||
|
||||
# --- 영속화 ---------------------------------------------------------
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def save(self, path: Path | None = None) -> Path:
|
||||
target = path or CONFIG_PATH
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
tmp.replace(target)
|
||||
return target
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> AppConfig:
|
||||
target = path or CONFIG_PATH
|
||||
if not target.exists():
|
||||
return cls()
|
||||
try:
|
||||
raw = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
log.warning("설정 파일을 읽지 못해 기본값을 사용합니다: %s", exc)
|
||||
return cls()
|
||||
return _from_dict(cls, raw)
|
||||
|
||||
|
||||
def _from_dict(cls: type, raw: Any) -> Any:
|
||||
"""알 수 없는 키는 버리고 누락된 키는 기본값으로 채우는 관대한 역직렬화.
|
||||
|
||||
기본값 인스턴스를 먼저 만든 뒤 알고 있는 필드만 덮어쓴다. 덕분에
|
||||
설정 스키마가 바뀌어도 사용자의 기존 config.json이 깨지지 않는다.
|
||||
"""
|
||||
instance = cls()
|
||||
if not isinstance(raw, dict):
|
||||
return instance
|
||||
known = {f.name for f in fields(cls)}
|
||||
for key, value in raw.items():
|
||||
if key not in known:
|
||||
continue
|
||||
current = getattr(instance, key)
|
||||
if is_dataclass(current) and not isinstance(current, type):
|
||||
setattr(instance, key, _from_dict(type(current), value))
|
||||
else:
|
||||
setattr(instance, key, value)
|
||||
return instance
|
||||
63
src/livesub/constants.py
Normal file
63
src/livesub/constants.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""전역 상수. 제품명 변경은 이 파일만 고치면 됩니다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "LiveSub"
|
||||
APP_NAME_KO = "라이브섭"
|
||||
APP_SLOGAN = "게임 소리를 실시간 자막으로"
|
||||
APP_VERSION = "0.2.0"
|
||||
ORG_NAME = "tkrmagid"
|
||||
|
||||
# 지원 언어 (1차: 4개)
|
||||
#
|
||||
# 각 키의 의미:
|
||||
# label — UI 표시 이름
|
||||
# english — 프롬프트에 쓰는 영어 언어명
|
||||
# nllb — NLLB-200 언어 코드 (FLORES-200 형식)
|
||||
# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고
|
||||
# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와
|
||||
# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다.
|
||||
LANGUAGES: dict[str, dict[str, str]] = {
|
||||
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"},
|
||||
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"},
|
||||
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"},
|
||||
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"},
|
||||
}
|
||||
LANGUAGE_CODES = tuple(LANGUAGES.keys())
|
||||
|
||||
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
|
||||
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
|
||||
#
|
||||
# 게임과 동시에 돌리는 것이 주 용도라 기본값을 '균형'(6GB)에서 '신속'(4GB)으로
|
||||
# 낮췄다. 인식 품질은 그대로 최상급이고 번역만 경량이라 체감 손해가 가장 적다.
|
||||
DEFAULT_TIER = "swift"
|
||||
|
||||
# 오디오 파이프라인 규격
|
||||
SAMPLE_RATE = 16_000
|
||||
CHANNELS = 1
|
||||
FRAME_MS = 20
|
||||
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
|
||||
|
||||
|
||||
def user_data_dir() -> Path:
|
||||
"""설정/로그/모델 캐시를 두는 사용자 디렉터리."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
path = base / APP_NAME
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def models_dir() -> Path:
|
||||
path = user_data_dir() / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
6
src/livesub/core/__init__.py
Normal file
6
src/livesub/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""파이프라인 코어."""
|
||||
|
||||
from .engine import TranslationEngine
|
||||
from .events import EngineState, EngineStatus, TranslationLine
|
||||
|
||||
__all__ = ["EngineState", "EngineStatus", "TranslationEngine", "TranslationLine"]
|
||||
309
src/livesub/core/engine.py
Normal file
309
src/livesub/core/engine.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""실시간 번역 파이프라인 오케스트레이션.
|
||||
|
||||
캡처 스레드 → [오디오 큐] → 분할 스레드 → [구간 큐] → 인식/번역 워커 → 콜백
|
||||
|
||||
UI 프레임워크에 의존하지 않는다. Qt 쪽은 콜백을 시그널로 다시 던져준다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..audio import (
|
||||
CaptureBackend,
|
||||
CaptureError,
|
||||
Segment,
|
||||
Segmenter,
|
||||
SegmenterConfig,
|
||||
create_capture,
|
||||
)
|
||||
from ..config import AppConfig
|
||||
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 .events import EngineState, EngineStatus, TranslationLine
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
StatusCallback = Callable[[EngineStatus], None]
|
||||
LineCallback = Callable[[TranslationLine], None]
|
||||
|
||||
|
||||
class TranslationEngine:
|
||||
def __init__(
|
||||
self,
|
||||
config: AppConfig,
|
||||
on_line: LineCallback | None = None,
|
||||
on_status: StatusCallback | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self._on_line = on_line
|
||||
self._on_status = on_status
|
||||
|
||||
self.models = ModelManager(config.models, low_power=config.performance.game_mode)
|
||||
self.glossary = self._load_glossary()
|
||||
|
||||
self._capture: CaptureBackend | None = None
|
||||
self._segments: queue.Queue[Segment] = queue.Queue(maxsize=32)
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._stop = threading.Event()
|
||||
self._status = EngineStatus()
|
||||
self._level = 0.0
|
||||
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
|
||||
self._last_final_text = ""
|
||||
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
|
||||
self._last_activity = time.monotonic()
|
||||
self._models_unloaded = False
|
||||
|
||||
# --- 수명주기 -------------------------------------------------------
|
||||
@property
|
||||
def status(self) -> EngineStatus:
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._status.state in (EngineState.RUNNING, EngineState.LOADING)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.running:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._emit_status(EngineState.LOADING, "모델을 준비하고 있습니다…", 0.0)
|
||||
|
||||
thread = threading.Thread(target=self._bootstrap, name="engine-boot", daemon=True)
|
||||
thread.start()
|
||||
self._threads = [thread]
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._status.state is EngineState.IDLE:
|
||||
return
|
||||
self._emit_status(EngineState.STOPPING, "정지 중…")
|
||||
self._stop.set()
|
||||
if self._capture is not None:
|
||||
self._capture.stop()
|
||||
self._capture = None
|
||||
for t in self._threads:
|
||||
if t is not threading.current_thread():
|
||||
t.join(timeout=3)
|
||||
self._threads = []
|
||||
self._drain_segments()
|
||||
self._emit_status(EngineState.IDLE, "대기 중")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.stop()
|
||||
self.models.unload()
|
||||
|
||||
def reload_glossary(self) -> None:
|
||||
self.glossary = self._load_glossary()
|
||||
|
||||
# --- 내부 스레드 ----------------------------------------------------
|
||||
def _bootstrap(self) -> None:
|
||||
try:
|
||||
perf = self.config.performance
|
||||
if perf.game_mode and self.config.models.device == "cuda":
|
||||
# 모델을 올리기 *전에* 걸어야 상한이 실제로 먹는다.
|
||||
apply_vram_limit(perf.vram_limit_ratio, self.config.models.device_index)
|
||||
if self.config.models.preload_on_start:
|
||||
self.models.preload(
|
||||
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
|
||||
)
|
||||
self._capture = create_capture(self.config.audio)
|
||||
self._capture.start()
|
||||
|
||||
workers = [
|
||||
threading.Thread(target=self._segment_loop, name="engine-segment", daemon=True),
|
||||
threading.Thread(target=self._infer_loop, name="engine-infer", daemon=True),
|
||||
]
|
||||
for w in workers:
|
||||
w.start()
|
||||
self._threads.extend(workers)
|
||||
|
||||
kind = "프로그램별 캡처" if self._capture.per_process else "출력 장치 전체"
|
||||
self._emit_status(EngineState.RUNNING, f"듣는 중 · {kind}", 1.0)
|
||||
except (CaptureError, OSError, RuntimeError, ImportError) as exc:
|
||||
log.exception("엔진 시작 실패")
|
||||
self._emit_status(EngineState.ERROR, str(exc))
|
||||
|
||||
def _segment_loop(self) -> None:
|
||||
cfg = self.config.audio
|
||||
# 중간 결과는 같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 된다.
|
||||
# 게임과 같이 돌릴 때는 그만한 값어치가 없으므로 저부하 모드에서는 끈다.
|
||||
partial_ms = 0 if self.config.performance.game_mode else cfg.partial_interval_ms
|
||||
segmenter = Segmenter(
|
||||
SegmenterConfig(
|
||||
silence_ms=cfg.silence_ms,
|
||||
min_segment_ms=cfg.min_segment_ms,
|
||||
max_segment_ms=cfg.max_segment_ms,
|
||||
partial_interval_ms=partial_ms,
|
||||
vad_aggressiveness=cfg.vad_aggressiveness,
|
||||
)
|
||||
)
|
||||
while not self._stop.is_set():
|
||||
capture = self._capture
|
||||
if capture is None:
|
||||
break
|
||||
chunk = capture.read(timeout=0.3)
|
||||
if chunk is None:
|
||||
if capture.error is not None:
|
||||
self._emit_status(EngineState.ERROR, str(capture.error))
|
||||
return
|
||||
continue
|
||||
|
||||
if cfg.input_gain != 1.0:
|
||||
chunk = np.clip(chunk * cfg.input_gain, -1.0, 1.0)
|
||||
self._level = float(np.sqrt(np.mean(np.square(chunk, dtype=np.float64))))
|
||||
|
||||
for segment in segmenter.push(chunk):
|
||||
self._enqueue(segment)
|
||||
|
||||
leftover = segmenter.flush()
|
||||
if leftover is not None:
|
||||
self._enqueue(leftover)
|
||||
|
||||
def _enqueue(self, segment: Segment) -> None:
|
||||
try:
|
||||
self._segments.put_nowait(segment)
|
||||
except queue.Full:
|
||||
# 밀렸다면 중간 결과부터 버린다. 확정 문장은 지켜야 한다.
|
||||
if not segment.is_final:
|
||||
return
|
||||
try:
|
||||
self._segments.get_nowait()
|
||||
self._segments.put_nowait(segment)
|
||||
except (queue.Empty, queue.Full):
|
||||
pass
|
||||
|
||||
def _infer_loop(self) -> None:
|
||||
mcfg = self.config.models
|
||||
perf = self.config.performance
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
segment = self._segments.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
self._maybe_unload_idle()
|
||||
continue
|
||||
|
||||
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
|
||||
if not segment.is_final and not self._segments.empty():
|
||||
continue
|
||||
|
||||
# 모델 참조를 캐시하지 않는다. 유휴 언로드 뒤에는 새로 올려야 하고,
|
||||
# 캐시된 참조를 쓰면 이미 내려간 모델을 붙잡고 있게 된다.
|
||||
try:
|
||||
self._process(
|
||||
segment, self.models.recognizer(), self.models.translator(), mcfg
|
||||
)
|
||||
self._last_activity = time.monotonic()
|
||||
self._models_unloaded = False
|
||||
except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다
|
||||
log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG))
|
||||
|
||||
# 게임에 GPU를 잠깐 돌려준다. 연속 발화 때 우리가 GPU를 독점하면
|
||||
# 프레임이 눈에 띄게 끊긴다.
|
||||
if perf.game_mode and perf.yield_ms > 0:
|
||||
self._stop.wait(perf.yield_ms / 1000)
|
||||
|
||||
def _maybe_unload_idle(self) -> None:
|
||||
"""한동안 말소리가 없으면 모델을 내려 VRAM을 통째로 돌려준다.
|
||||
|
||||
게임 중에 조용한 구간(로딩·메뉴·솔로 플레이)이 길게 이어지는데
|
||||
그동안 수 GB를 붙잡고 있을 이유가 없다. 다음 발화가 오면 자동으로
|
||||
다시 올라간다 — 그때 한 번 로딩 지연이 생기는 것이 트레이드오프다.
|
||||
"""
|
||||
perf = self.config.performance
|
||||
if not perf.game_mode or perf.idle_unload_s <= 0 or self._models_unloaded:
|
||||
return
|
||||
if time.monotonic() - self._last_activity < perf.idle_unload_s:
|
||||
return
|
||||
if not self.models.ready:
|
||||
self._models_unloaded = True
|
||||
return
|
||||
log.info("%d초간 조용해 모델을 내립니다 (VRAM 반납)", perf.idle_unload_s)
|
||||
self.models.unload()
|
||||
self._models_unloaded = True
|
||||
self._emit_status(EngineState.RUNNING, "듣는 중 · 대기 (VRAM 반납됨)")
|
||||
|
||||
def _process(self, segment: Segment, recognizer, translator, mcfg) -> None:
|
||||
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
|
||||
|
||||
t0 = time.perf_counter()
|
||||
transcript = recognizer.transcribe(
|
||||
segment.audio,
|
||||
language=source_lang,
|
||||
fast=not segment.is_final,
|
||||
prompt=self._last_final_text if segment.is_final else "",
|
||||
)
|
||||
asr_ms = int((time.perf_counter() - t0) * 1000)
|
||||
if not transcript.text:
|
||||
return
|
||||
|
||||
detected = transcript.language if transcript.language in LANGUAGE_CODES else mcfg.target_lang
|
||||
target = mcfg.target_lang
|
||||
|
||||
translated = ""
|
||||
mt_ms = 0
|
||||
if segment.is_final:
|
||||
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
|
||||
t1 = time.perf_counter()
|
||||
translated = translator.translate(
|
||||
transcript.text,
|
||||
detected,
|
||||
target,
|
||||
self.glossary if self.config.glossary.enabled else None,
|
||||
)
|
||||
mt_ms = int((time.perf_counter() - t1) * 1000)
|
||||
self._last_final_text = transcript.text
|
||||
|
||||
line = TranslationLine(
|
||||
source_text=transcript.text,
|
||||
translated_text=translated,
|
||||
source_lang=detected,
|
||||
target_lang=target,
|
||||
is_final=segment.is_final,
|
||||
latency_s=time.monotonic() - segment.ended_at,
|
||||
asr_ms=asr_ms,
|
||||
mt_ms=mt_ms,
|
||||
)
|
||||
if self._on_line:
|
||||
self._on_line(line)
|
||||
self._status.level = self._level
|
||||
self._status.queue_depth = self._segments.qsize()
|
||||
|
||||
# --- 유틸 -----------------------------------------------------------
|
||||
def _load_glossary(self) -> Glossary:
|
||||
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
|
||||
cfg = self.config.glossary
|
||||
path = cfg.path or str(user_data_dir() / "glossary.json")
|
||||
user = Glossary.load(path, cfg.case_sensitive)
|
||||
merged = build_glossary(user, cfg.enabled_packs, cfg.case_sensitive)
|
||||
log.info(
|
||||
"용어집 %d개 (사용자 %d + 팩 %s)",
|
||||
len(merged), len(user), ", ".join(cfg.enabled_packs) or "없음",
|
||||
)
|
||||
return merged
|
||||
|
||||
def _drain_segments(self) -> None:
|
||||
while not self._segments.empty():
|
||||
try:
|
||||
self._segments.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def _emit_status(self, state: EngineState, message: str, progress: float | None = None) -> None:
|
||||
self._status = EngineStatus(
|
||||
state=state,
|
||||
message=message,
|
||||
progress=self._status.progress if progress is None else progress,
|
||||
level=self._level,
|
||||
queue_depth=self._segments.qsize(),
|
||||
)
|
||||
if self._on_status:
|
||||
self._on_status(self._status)
|
||||
45
src/livesub/core/events.py
Normal file
45
src/livesub/core/events.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""엔진이 UI로 올려보내는 이벤트 타입."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EngineState(str, Enum):
|
||||
IDLE = "idle"
|
||||
LOADING = "loading"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranslationLine:
|
||||
"""화면에 뿌릴 자막 한 줄."""
|
||||
|
||||
source_text: str
|
||||
translated_text: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
is_final: bool = True
|
||||
created_at: float = field(default_factory=time.time)
|
||||
#: 발화가 끝난 시점부터 번역이 나오기까지 걸린 시간(초)
|
||||
latency_s: float = 0.0
|
||||
asr_ms: int = 0
|
||||
mt_ms: int = 0
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
return self.translated_text or self.source_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineStatus:
|
||||
state: EngineState = EngineState.IDLE
|
||||
message: str = ""
|
||||
progress: float = 0.0
|
||||
#: 최근 입력 오디오 레벨 (0.0 ~ 1.0), VU 미터용
|
||||
level: float = 0.0
|
||||
queue_depth: int = 0
|
||||
53
src/livesub/resources/glossaries/fps-common.json
Normal file
53
src/livesub/resources/glossaries/fps-common.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"key": "fps-common",
|
||||
"name": "FPS 공통 (배그·CS·에이펙스)",
|
||||
"description": "총기·교전·파티 콜처럼 슈터 장르 전반에서 공통으로 쓰는 말.",
|
||||
"entries": [
|
||||
{"source": "enemy", "targets": {"ko": "적", "ja": "敵", "zh": "敌人"}},
|
||||
{"source": "enemy spotted", "targets": {"ko": "적 발견", "ja": "敵発見", "zh": "发现敌人"}},
|
||||
{"source": "contact", "targets": {"ko": "교전", "ja": "接敵", "zh": "接敌"}},
|
||||
{"source": "reloading", "targets": {"ko": "재장전 중", "ja": "リロード中", "zh": "换弹中"}},
|
||||
{"source": "cover me", "targets": {"ko": "엄호해줘", "ja": "援護して", "zh": "掩护我"}},
|
||||
{"source": "covering", "targets": {"ko": "엄호 중", "ja": "援護中", "zh": "掩护中"}},
|
||||
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}},
|
||||
{"source": "fall back", "targets": {"ko": "후퇴", "ja": "後退", "zh": "撤退"}},
|
||||
{"source": "hold", "targets": {"ko": "대기", "ja": "ホールド", "zh": "守住"}},
|
||||
{"source": "knocked", "targets": {"ko": "기절", "ja": "ダウン", "zh": "被击倒"}},
|
||||
{"source": "down", "targets": {"ko": "다운", "ja": "ダウン", "zh": "倒地"}},
|
||||
{"source": "revive", "targets": {"ko": "부활", "ja": "リヴァイヴ", "zh": "复活"}},
|
||||
{"source": "res", "targets": {"ko": "살려줘", "ja": "蘇生して", "zh": "救我"}},
|
||||
{"source": "heal up", "targets": {"ko": "회복하자", "ja": "回復しよう", "zh": "回血"}},
|
||||
{"source": "loot", "targets": {"ko": "파밍", "ja": "ルート", "zh": "搜物资"}},
|
||||
{"source": "looting", "targets": {"ko": "파밍 중", "ja": "ルート中", "zh": "搜物资中"}},
|
||||
{"source": "third party", "targets": {"ko": "어부", "ja": "漁夫", "zh": "第三方"}},
|
||||
{"source": "zone", "targets": {"ko": "자기장", "ja": "安全地帯", "zh": "毒圈"}},
|
||||
{"source": "circle", "targets": {"ko": "자기장", "ja": "サークル", "zh": "圈"}},
|
||||
{"source": "storm", "targets": {"ko": "폭풍", "ja": "ストーム", "zh": "风暴"}},
|
||||
{"source": "high ground", "targets": {"ko": "고지", "ja": "高所", "zh": "高地"}},
|
||||
{"source": "flank", "targets": {"ko": "측면", "ja": "側面", "zh": "侧翼"}},
|
||||
{"source": "behind us", "targets": {"ko": "뒤에 있다", "ja": "後ろにいる", "zh": "背后有人"}},
|
||||
{"source": "on me", "targets": {"ko": "나한테 붙어", "ja": "ついてきて", "zh": "跟着我"}},
|
||||
{"source": "grenade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
|
||||
{"source": "nade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
|
||||
{"source": "frag", "targets": {"ko": "파편탄", "ja": "フラグ", "zh": "破片雷"}},
|
||||
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟雾弹"}},
|
||||
{"source": "flashbang", "targets": {"ko": "섬광탄", "ja": "フラッシュ", "zh": "闪光弹"}},
|
||||
{"source": "scope", "targets": {"ko": "조준경", "ja": "スコープ", "zh": "瞄准镜"}},
|
||||
{"source": "ADS", "targets": {"ko": "정조준", "ja": "ADS", "zh": "开镜"}},
|
||||
{"source": "recoil", "targets": {"ko": "반동", "ja": "リコイル", "zh": "后坐力"}},
|
||||
{"source": "spray control", "targets": {"ko": "반동 제어", "ja": "リコイル制御", "zh": "压枪"}},
|
||||
{"source": "aim", "targets": {"ko": "에임", "ja": "エイム", "zh": "枪法"}},
|
||||
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
|
||||
{"source": "wall bang", "targets": {"ko": "관통샷", "ja": "ウォールバン", "zh": "穿墙"}},
|
||||
{"source": "prefire", "targets": {"ko": "선샷", "ja": "プリファイア", "zh": "预瞄"}},
|
||||
{"source": "camping", "targets": {"ko": "캠핑", "ja": "キャンプ", "zh": "蹲点"}},
|
||||
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
|
||||
{"source": "kill streak", "targets": {"ko": "연속 킬", "ja": "キルストリーク", "zh": "连杀"}},
|
||||
{"source": "ammo", "targets": {"ko": "탄약", "ja": "弾薬", "zh": "子弹"}},
|
||||
{"source": "out of ammo", "targets": {"ko": "탄 없음", "ja": "弾切れ", "zh": "没子弹"}},
|
||||
{"source": "backup", "targets": {"ko": "지원", "ja": "バックアップ", "zh": "支援"}},
|
||||
{"source": "good game", "targets": {"ko": "잘 했다", "ja": "GG", "zh": "打得好"}},
|
||||
{"source": "nice shot", "targets": {"ko": "잘 쐈다", "ja": "ナイスショット", "zh": "好枪"}},
|
||||
{"source": "my bad", "targets": {"ko": "내 실수", "ja": "ごめん", "zh": "我的错"}}
|
||||
]
|
||||
}
|
||||
81
src/livesub/resources/glossaries/lol.json
Normal file
81
src/livesub/resources/glossaries/lol.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"key": "lol",
|
||||
"name": "리그 오브 레전드",
|
||||
"description": "라인·오브젝트·교전 콜, 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
|
||||
"entries": [
|
||||
{"source": "Nexus", "targets": {"ko": "넥서스", "ja": "ネクサス", "zh": "基地"}},
|
||||
{"source": "inhibitor", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
|
||||
{"source": "inhib", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
|
||||
{"source": "turret", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
|
||||
{"source": "tower", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
|
||||
{"source": "baron", "targets": {"ko": "바론", "ja": "バロン", "zh": "大龙"}},
|
||||
{"source": "Baron Nashor", "targets": {"ko": "내셔 남작", "ja": "バロン・ナッシャー", "zh": "纳什男爵"}},
|
||||
{"source": "dragon", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
|
||||
{"source": "drake", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
|
||||
{"source": "elder", "targets": {"ko": "장로 드래곤", "ja": "エルダードラゴン", "zh": "远古龙"}},
|
||||
{"source": "herald", "targets": {"ko": "전령", "ja": "ヘラルド", "zh": "峡谷先锋"}},
|
||||
{"source": "Rift Herald", "targets": {"ko": "협곡의 전령", "ja": "リフトヘラルド", "zh": "峡谷先锋"}},
|
||||
{"source": "grubs", "targets": {"ko": "유충", "ja": "虫", "zh": "虚空幼虫"}},
|
||||
{"source": "gank", "targets": {"ko": "갱", "ja": "ガンク", "zh": "抓人"}},
|
||||
{"source": "ganking", "targets": {"ko": "갱킹", "ja": "ガンク", "zh": "抓人"}},
|
||||
{"source": "counter gank", "targets": {"ko": "역갱", "ja": "カウンターガンク", "zh": "反蹲"}},
|
||||
{"source": "roam", "targets": {"ko": "로밍", "ja": "ローム", "zh": "游走"}},
|
||||
{"source": "jungle", "targets": {"ko": "정글", "ja": "ジャングル", "zh": "打野"}},
|
||||
{"source": "jungler", "targets": {"ko": "정글러", "ja": "ジャングラー", "zh": "打野"}},
|
||||
{"source": "top lane", "targets": {"ko": "탑", "ja": "トップ", "zh": "上路"}},
|
||||
{"source": "mid lane", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
|
||||
{"source": "bot lane", "targets": {"ko": "바텀", "ja": "ボット", "zh": "下路"}},
|
||||
{"source": "support", "targets": {"ko": "서폿", "ja": "サポート", "zh": "辅助"}},
|
||||
{"source": "ADC", "targets": {"ko": "원딜", "ja": "ADC", "zh": "ADC"}},
|
||||
{"source": "minion", "targets": {"ko": "미니언", "ja": "ミニオン", "zh": "小兵"}},
|
||||
{"source": "wave", "targets": {"ko": "웨이브", "ja": "ウェーブ", "zh": "兵线"}},
|
||||
{"source": "creep score", "targets": {"ko": "CS", "ja": "CS", "zh": "补刀"}},
|
||||
{"source": "last hit", "targets": {"ko": "막타", "ja": "ラストヒット", "zh": "补刀"}},
|
||||
{"source": "freeze", "targets": {"ko": "프리징", "ja": "フリーズ", "zh": "控线"}},
|
||||
{"source": "push", "targets": {"ko": "푸시", "ja": "プッシュ", "zh": "推线"}},
|
||||
{"source": "split push", "targets": {"ko": "스플릿", "ja": "スプリットプッシュ", "zh": "分推"}},
|
||||
{"source": "back", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
|
||||
{"source": "recall", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
|
||||
{"source": "ward", "targets": {"ko": "와드", "ja": "ワード", "zh": "眼"}},
|
||||
{"source": "vision", "targets": {"ko": "시야", "ja": "視界", "zh": "视野"}},
|
||||
{"source": "flash", "targets": {"ko": "점멸", "ja": "フラッシュ", "zh": "闪现"}},
|
||||
{"source": "ignite", "targets": {"ko": "점화", "ja": "イグナイト", "zh": "点燃"}},
|
||||
{"source": "teleport", "targets": {"ko": "순간이동", "ja": "テレポート", "zh": "传送"}},
|
||||
{"source": "smite", "targets": {"ko": "강타", "ja": "スマイト", "zh": "惩戒"}},
|
||||
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
|
||||
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "大招"}},
|
||||
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
|
||||
{"source": "on cooldown", "targets": {"ko": "쿨 도는 중", "ja": "クール中", "zh": "冷却中"}},
|
||||
{"source": "engage", "targets": {"ko": "이니시", "ja": "エンゲージ", "zh": "开团"}},
|
||||
{"source": "disengage", "targets": {"ko": "빠지기", "ja": "ディスエンゲージ", "zh": "撤退"}},
|
||||
{"source": "team fight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
|
||||
{"source": "teamfight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
|
||||
{"source": "peel", "targets": {"ko": "피어싱 보호", "ja": "ピール", "zh": "保护"}},
|
||||
{"source": "kite", "targets": {"ko": "카이팅", "ja": "カイト", "zh": "风筝"}},
|
||||
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
|
||||
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "越塔"}},
|
||||
{"source": "tower dive", "targets": {"ko": "타워 다이브", "ja": "タワーダイブ", "zh": "越塔强杀"}},
|
||||
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "团灭"}},
|
||||
{"source": "first blood", "targets": {"ko": "퍼블", "ja": "ファーストブラッド", "zh": "一血"}},
|
||||
{"source": "shutdown", "targets": {"ko": "현상금", "ja": "シャットダウン", "zh": "赏金"}},
|
||||
{"source": "respawn", "targets": {"ko": "부활", "ja": "リスポーン", "zh": "复活"}},
|
||||
{"source": "missing", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
|
||||
{"source": "MIA", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
|
||||
{"source": "SS", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
|
||||
{"source": "fed", "targets": {"ko": "잘 큰", "ja": "育った", "zh": "养肥"}},
|
||||
{"source": "scaling", "targets": {"ko": "성장력", "ja": "スケーリング", "zh": "后期"}},
|
||||
{"source": "objective", "targets": {"ko": "오브젝트", "ja": "オブジェクト", "zh": "资源"}},
|
||||
{"source": "backdoor", "targets": {"ko": "백도어", "ja": "バックドア", "zh": "偷家"}},
|
||||
{"source": "surrender", "targets": {"ko": "항복", "ja": "サレンダー", "zh": "投降"}},
|
||||
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "增益"}},
|
||||
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
|
||||
{"source": "CC", "targets": {"ko": "군중제어", "ja": "CC", "zh": "控制"}},
|
||||
{"source": "stun", "targets": {"ko": "기절", "ja": "スタン", "zh": "眩晕"}},
|
||||
{"source": "slow", "targets": {"ko": "둔화", "ja": "スロウ", "zh": "减速"}},
|
||||
{"source": "shield", "targets": {"ko": "실드", "ja": "シールド", "zh": "护盾"}},
|
||||
{"source": "heal", "targets": {"ko": "회복", "ja": "ヒール", "zh": "治疗"}},
|
||||
{"source": "mana", "targets": {"ko": "마나", "ja": "マナ", "zh": "蓝"}},
|
||||
{"source": "low HP", "targets": {"ko": "체력 낮음", "ja": "体力低い", "zh": "残血"}},
|
||||
{"source": "full build", "targets": {"ko": "풀템", "ja": "フルビルド", "zh": "满装备"}}
|
||||
]
|
||||
}
|
||||
45
src/livesub/resources/glossaries/minecraft.json
Normal file
45
src/livesub/resources/glossaries/minecraft.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"key": "minecraft",
|
||||
"name": "마인크래프트",
|
||||
"description": "블록·몹·아이템 이름. 한국어판 공식 번역어를 따랐습니다.",
|
||||
"entries": [
|
||||
{"source": "creeper", "targets": {"ko": "크리퍼", "ja": "クリーパー", "zh": "苦力怕"}},
|
||||
{"source": "enderman", "targets": {"ko": "엔더맨", "ja": "エンダーマン", "zh": "末影人"}},
|
||||
{"source": "ender dragon", "targets": {"ko": "엔더 드래곤", "ja": "エンダードラゴン", "zh": "末影龙"}},
|
||||
{"source": "wither", "targets": {"ko": "위더", "ja": "ウィザー", "zh": "凋灵"}},
|
||||
{"source": "skeleton", "targets": {"ko": "스켈레톤", "ja": "スケルトン", "zh": "骷髅"}},
|
||||
{"source": "zombie", "targets": {"ko": "좀비", "ja": "ゾンビ", "zh": "僵尸"}},
|
||||
{"source": "piglin", "targets": {"ko": "피글린", "ja": "ピグリン", "zh": "猪灵"}},
|
||||
{"source": "villager", "targets": {"ko": "주민", "ja": "村人", "zh": "村民"}},
|
||||
{"source": "the Nether", "targets": {"ko": "네더", "ja": "ネザー", "zh": "下界"}},
|
||||
{"source": "the End", "targets": {"ko": "엔드", "ja": "ジ・エンド", "zh": "末地"}},
|
||||
{"source": "Overworld", "targets": {"ko": "오버월드", "ja": "オーバーワールド", "zh": "主世界"}},
|
||||
{"source": "nether portal", "targets": {"ko": "네더 차원문", "ja": "ネザーゲート", "zh": "下界传送门"}},
|
||||
{"source": "stronghold", "targets": {"ko": "요새", "ja": "要塞", "zh": "要塞"}},
|
||||
{"source": "bastion", "targets": {"ko": "잔해 성채", "ja": "砦の残骸", "zh": "堡垒遗迹"}},
|
||||
{"source": "diamond", "targets": {"ko": "다이아몬드", "ja": "ダイヤ", "zh": "钻石"}},
|
||||
{"source": "netherite", "targets": {"ko": "네더라이트", "ja": "ネザライト", "zh": "下界合金"}},
|
||||
{"source": "obsidian", "targets": {"ko": "흑요석", "ja": "黒曜石", "zh": "黑曜石"}},
|
||||
{"source": "redstone", "targets": {"ko": "레드스톤", "ja": "レッドストーン", "zh": "红石"}},
|
||||
{"source": "ender pearl", "targets": {"ko": "엔더 진주", "ja": "エンダーパール", "zh": "末影珍珠"}},
|
||||
{"source": "blaze rod", "targets": {"ko": "블레이즈 막대", "ja": "ブレイズロッド", "zh": "烈焰棒"}},
|
||||
{"source": "elytra", "targets": {"ko": "겉날개", "ja": "エリトラ", "zh": "鞘翅"}},
|
||||
{"source": "totem", "targets": {"ko": "불사의 토템", "ja": "不死のトーテム", "zh": "不死图腾"}},
|
||||
{"source": "enchant", "targets": {"ko": "인챈트", "ja": "エンチャント", "zh": "附魔"}},
|
||||
{"source": "anvil", "targets": {"ko": "모루", "ja": "金床", "zh": "铁砧"}},
|
||||
{"source": "crafting table", "targets": {"ko": "작업대", "ja": "作業台", "zh": "工作台"}},
|
||||
{"source": "furnace", "targets": {"ko": "화로", "ja": "かまど", "zh": "熔炉"}},
|
||||
{"source": "chest", "targets": {"ko": "상자", "ja": "チェスト", "zh": "箱子"}},
|
||||
{"source": "spawn point", "targets": {"ko": "스폰 지점", "ja": "スポーン地点", "zh": "出生点"}},
|
||||
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
|
||||
{"source": "biome", "targets": {"ko": "생물 군계", "ja": "バイオーム", "zh": "生物群系"}},
|
||||
{"source": "seed", "targets": {"ko": "시드", "ja": "シード", "zh": "种子"}},
|
||||
{"source": "mob", "targets": {"ko": "몹", "ja": "モブ", "zh": "生物"}},
|
||||
{"source": "hardcore", "targets": {"ko": "하드코어", "ja": "ハードコア", "zh": "极限模式"}},
|
||||
{"source": "speedrun", "targets": {"ko": "스피드런", "ja": "RTA", "zh": "速通"}},
|
||||
{"source": "mine", "targets": {"ko": "채굴", "ja": "採掘", "zh": "挖矿"}},
|
||||
{"source": "craft", "targets": {"ko": "제작", "ja": "クラフト", "zh": "合成"}},
|
||||
{"source": "build", "targets": {"ko": "건축", "ja": "建築", "zh": "建造"}},
|
||||
{"source": "farm", "targets": {"ko": "농장", "ja": "トラップ", "zh": "农场"}}
|
||||
]
|
||||
}
|
||||
56
src/livesub/resources/glossaries/overwatch.json
Normal file
56
src/livesub/resources/glossaries/overwatch.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"key": "overwatch",
|
||||
"name": "오버워치 2",
|
||||
"description": "역할·궁·거점 콜. 한국 공식 번역어를 우선했습니다.",
|
||||
"entries": [
|
||||
{"source": "tank", "targets": {"ko": "탱커", "ja": "タンク", "zh": "重装"}},
|
||||
{"source": "DPS", "targets": {"ko": "딜러", "ja": "ダメージ", "zh": "输出"}},
|
||||
{"source": "damage", "targets": {"ko": "딜", "ja": "ダメージ", "zh": "伤害"}},
|
||||
{"source": "support", "targets": {"ko": "지원가", "ja": "サポート", "zh": "辅助"}},
|
||||
{"source": "healer", "targets": {"ko": "힐러", "ja": "ヒーラー", "zh": "奶"}},
|
||||
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
|
||||
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
|
||||
{"source": "ult charge", "targets": {"ko": "궁 게이지", "ja": "アルトゲージ", "zh": "大招能量"}},
|
||||
{"source": "ult economy", "targets": {"ko": "궁 경제", "ja": "アルト回し", "zh": "大招经济"}},
|
||||
{"source": "combo", "targets": {"ko": "연계", "ja": "コンボ", "zh": "连招"}},
|
||||
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "突进"}},
|
||||
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
|
||||
{"source": "brawl", "targets": {"ko": "난전", "ja": "ブロール", "zh": "近战团"}},
|
||||
{"source": "payload", "targets": {"ko": "화물", "ja": "ペイロード", "zh": "运载目标"}},
|
||||
{"source": "point", "targets": {"ko": "거점", "ja": "拠点", "zh": "点"}},
|
||||
{"source": "capture", "targets": {"ko": "점령", "ja": "占領", "zh": "占点"}},
|
||||
{"source": "contest", "targets": {"ko": "저지", "ja": "コンテスト", "zh": "拖点"}},
|
||||
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
|
||||
{"source": "checkpoint", "targets": {"ko": "관문", "ja": "チェックポイント", "zh": "检查点"}},
|
||||
{"source": "spawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生点"}},
|
||||
{"source": "regroup", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
|
||||
{"source": "group up", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
|
||||
{"source": "stagger", "targets": {"ko": "스태거", "ja": "スタッガー", "zh": "送人头"}},
|
||||
{"source": "flank", "targets": {"ko": "측면", "ja": "フランク", "zh": "绕后"}},
|
||||
{"source": "high ground", "targets": {"ko": "고지", "ja": "高台", "zh": "高地"}},
|
||||
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
|
||||
{"source": "barrier", "targets": {"ko": "방벽", "ja": "バリア", "zh": "护盾"}},
|
||||
{"source": "shield", "targets": {"ko": "보호막", "ja": "シールド", "zh": "护盾"}},
|
||||
{"source": "armor", "targets": {"ko": "방어력", "ja": "アーマー", "zh": "护甲"}},
|
||||
{"source": "burst", "targets": {"ko": "순간 딜", "ja": "バースト", "zh": "爆发"}},
|
||||
{"source": "focus", "targets": {"ko": "집중 공격", "ja": "フォーカス", "zh": "集火"}},
|
||||
{"source": "peel", "targets": {"ko": "보호", "ja": "ピール", "zh": "保护"}},
|
||||
{"source": "nano", "targets": {"ko": "나노", "ja": "ナノ", "zh": "纳米"}},
|
||||
{"source": "beat", "targets": {"ko": "비트", "ja": "ビート", "zh": "音障"}},
|
||||
{"source": "sound barrier", "targets": {"ko": "음파 방벽", "ja": "サウンドバリア", "zh": "音障"}},
|
||||
{"source": "rez", "targets": {"ko": "부활", "ja": "リザ", "zh": "复活"}},
|
||||
{"source": "resurrect", "targets": {"ko": "부활", "ja": "リザレクト", "zh": "复活"}},
|
||||
{"source": "discord", "targets": {"ko": "불화의 구슬", "ja": "ディスコード", "zh": "不和之球"}},
|
||||
{"source": "hack", "targets": {"ko": "해킹", "ja": "ハック", "zh": "黑入"}},
|
||||
{"source": "EMP", "targets": {"ko": "EMP", "ja": "EMP", "zh": "EMP"}},
|
||||
{"source": "team wipe", "targets": {"ko": "전멸", "ja": "全滅", "zh": "团灭"}},
|
||||
{"source": "team fight", "targets": {"ko": "한타", "ja": "チームファイト", "zh": "团战"}},
|
||||
{"source": "counter pick", "targets": {"ko": "카운터 픽", "ja": "カウンターピック", "zh": "针对英雄"}},
|
||||
{"source": "swap", "targets": {"ko": "영웅 교체", "ja": "ヒーロー変更", "zh": "换英雄"}},
|
||||
{"source": "health pack", "targets": {"ko": "회복팩", "ja": "回復パック", "zh": "血包"}},
|
||||
{"source": "critical", "targets": {"ko": "치명타", "ja": "クリティカル", "zh": "暴击"}},
|
||||
{"source": "behind you", "targets": {"ko": "뒤에 있다", "ja": "後ろだ", "zh": "背后有人"}},
|
||||
{"source": "falling back", "targets": {"ko": "후퇴한다", "ja": "後退する", "zh": "撤退"}},
|
||||
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}}
|
||||
]
|
||||
}
|
||||
45
src/livesub/resources/glossaries/streaming.json
Normal file
45
src/livesub/resources/glossaries/streaming.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"key": "streaming",
|
||||
"name": "방송·스트리밍",
|
||||
"description": "해외 방송을 볼 때 자주 나오는 말. 게임 용어집과 같이 켜면 좋습니다.",
|
||||
"entries": [
|
||||
{"source": "stream", "targets": {"ko": "방송", "ja": "配信", "zh": "直播"}},
|
||||
{"source": "streamer", "targets": {"ko": "스트리머", "ja": "配信者", "zh": "主播"}},
|
||||
{"source": "chat", "targets": {"ko": "채팅", "ja": "チャット", "zh": "弹幕"}},
|
||||
{"source": "donation", "targets": {"ko": "후원", "ja": "投げ銭", "zh": "打赏"}},
|
||||
{"source": "donate", "targets": {"ko": "후원하다", "ja": "投げ銭する", "zh": "打赏"}},
|
||||
{"source": "sub", "targets": {"ko": "구독", "ja": "サブスク", "zh": "订阅"}},
|
||||
{"source": "subscriber", "targets": {"ko": "구독자", "ja": "登録者", "zh": "订阅者"}},
|
||||
{"source": "follow", "targets": {"ko": "팔로우", "ja": "フォロー", "zh": "关注"}},
|
||||
{"source": "raid", "targets": {"ko": "합방 몰아주기", "ja": "レイド", "zh": "转播"}},
|
||||
{"source": "mod", "targets": {"ko": "매니저", "ja": "モデレーター", "zh": "房管"}},
|
||||
{"source": "clip", "targets": {"ko": "클립", "ja": "クリップ", "zh": "切片"}},
|
||||
{"source": "highlight", "targets": {"ko": "하이라이트", "ja": "ハイライト", "zh": "高光"}},
|
||||
{"source": "VOD", "targets": {"ko": "다시보기", "ja": "アーカイブ", "zh": "录播"}},
|
||||
{"source": "lag", "targets": {"ko": "렉", "ja": "ラグ", "zh": "卡"}},
|
||||
{"source": "ping", "targets": {"ko": "핑", "ja": "ピン", "zh": "延迟"}},
|
||||
{"source": "frame drop", "targets": {"ko": "프레임 드랍", "ja": "フレーム落ち", "zh": "掉帧"}},
|
||||
{"source": "patch", "targets": {"ko": "패치", "ja": "パッチ", "zh": "补丁"}},
|
||||
{"source": "meta", "targets": {"ko": "메타", "ja": "メタ", "zh": "版本环境"}},
|
||||
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
|
||||
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "加强"}},
|
||||
{"source": "ranked", "targets": {"ko": "랭크", "ja": "ランク", "zh": "排位"}},
|
||||
{"source": "casual", "targets": {"ko": "일반전", "ja": "カジュアル", "zh": "休闲"}},
|
||||
{"source": "smurf", "targets": {"ko": "부계정", "ja": "サブ垢", "zh": "小号"}},
|
||||
{"source": "grind", "targets": {"ko": "노가다", "ja": "周回", "zh": "刷"}},
|
||||
{"source": "carry", "targets": {"ko": "캐리", "ja": "キャリー", "zh": "带飞"}},
|
||||
{"source": "throw", "targets": {"ko": "던지다", "ja": "スローする", "zh": "送"}},
|
||||
{"source": "griefing", "targets": {"ko": "트롤링", "ja": "荒らし", "zh": "演员"}},
|
||||
{"source": "toxic", "targets": {"ko": "매너 나쁜", "ja": "民度が低い", "zh": "喷子"}},
|
||||
{"source": "cracked", "targets": {"ko": "미쳤다", "ja": "ヤバい", "zh": "太强了"}},
|
||||
{"source": "insane", "targets": {"ko": "대박", "ja": "やばい", "zh": "离谱"}},
|
||||
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "关键时刻"}},
|
||||
{"source": "let's go", "targets": {"ko": "가자", "ja": "行くぞ", "zh": "冲"}},
|
||||
{"source": "no way", "targets": {"ko": "말도 안 돼", "ja": "ありえない", "zh": "不可能吧"}},
|
||||
{"source": "give me a second", "targets": {"ko": "잠깐만", "ja": "ちょっと待って", "zh": "等一下"}},
|
||||
{"source": "hold on", "targets": {"ko": "잠깐", "ja": "待って", "zh": "等等"}},
|
||||
{"source": "be right back", "targets": {"ko": "금방 올게", "ja": "すぐ戻る", "zh": "马上回来"}},
|
||||
{"source": "thanks for the sub", "targets": {"ko": "구독 고마워", "ja": "サブありがとう", "zh": "谢谢订阅"}},
|
||||
{"source": "welcome back", "targets": {"ko": "다시 왔네", "ja": "おかえり", "zh": "欢迎回来"}}
|
||||
]
|
||||
}
|
||||
66
src/livesub/resources/glossaries/valorant.json
Normal file
66
src/livesub/resources/glossaries/valorant.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"key": "valorant",
|
||||
"name": "발로란트",
|
||||
"description": "스파이크·유틸·이코노미 콜. 아시아 서버 영어 콜아웃에 맞췄습니다.",
|
||||
"entries": [
|
||||
{"source": "spike", "targets": {"ko": "스파이크", "ja": "スパイク", "zh": "斩杀者"}},
|
||||
{"source": "plant", "targets": {"ko": "설치", "ja": "設置", "zh": "安装"}},
|
||||
{"source": "planted", "targets": {"ko": "설치됐다", "ja": "設置された", "zh": "已安装"}},
|
||||
{"source": "defuse", "targets": {"ko": "해체", "ja": "解除", "zh": "拆包"}},
|
||||
{"source": "defusing", "targets": {"ko": "해체 중", "ja": "解除中", "zh": "拆包中"}},
|
||||
{"source": "fake defuse", "targets": {"ko": "페이크 해체", "ja": "フェイク解除", "zh": "假拆"}},
|
||||
{"source": "post plant", "targets": {"ko": "설치 후", "ja": "ポストプラント", "zh": "安装后"}},
|
||||
{"source": "retake", "targets": {"ko": "리테이크", "ja": "リテイク", "zh": "残局夺回"}},
|
||||
{"source": "site", "targets": {"ko": "사이트", "ja": "サイト", "zh": "包点"}},
|
||||
{"source": "A site", "targets": {"ko": "A사이트", "ja": "Aサイト", "zh": "A点"}},
|
||||
{"source": "B site", "targets": {"ko": "B사이트", "ja": "Bサイト", "zh": "B点"}},
|
||||
{"source": "mid", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
|
||||
{"source": "rotate", "targets": {"ko": "로테", "ja": "ローテ", "zh": "换点"}},
|
||||
{"source": "rotating", "targets": {"ko": "로테 중", "ja": "ローテ中", "zh": "换点中"}},
|
||||
{"source": "eco", "targets": {"ko": "이코", "ja": "エコ", "zh": "经济局"}},
|
||||
{"source": "full eco", "targets": {"ko": "풀이코", "ja": "フルエコ", "zh": "全经济局"}},
|
||||
{"source": "eco round", "targets": {"ko": "이코 라운드", "ja": "エコラウンド", "zh": "经济局"}},
|
||||
{"source": "save", "targets": {"ko": "세이브", "ja": "セーブ", "zh": "存钱"}},
|
||||
{"source": "buy round", "targets": {"ko": "풀 구매", "ja": "フルバイ", "zh": "全起"}},
|
||||
{"source": "force buy", "targets": {"ko": "강제 구매", "ja": "フォースバイ", "zh": "强起"}},
|
||||
{"source": "pistol round", "targets": {"ko": "권총 라운드", "ja": "ピストルラウンド", "zh": "手枪局"}},
|
||||
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
|
||||
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
|
||||
{"source": "ult orb", "targets": {"ko": "궁 오브", "ja": "アルトオーブ", "zh": "能量球"}},
|
||||
{"source": "util", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
|
||||
{"source": "utility", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
|
||||
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟"}},
|
||||
{"source": "flash", "targets": {"ko": "섬광", "ja": "フラッシュ", "zh": "闪"}},
|
||||
{"source": "flashed", "targets": {"ko": "섬광 맞음", "ja": "フラッシュ食らった", "zh": "被闪了"}},
|
||||
{"source": "molly", "targets": {"ko": "몰리", "ja": "モリー", "zh": "火"}},
|
||||
{"source": "wall", "targets": {"ko": "벽", "ja": "ウォール", "zh": "墙"}},
|
||||
{"source": "recon", "targets": {"ko": "정찰", "ja": "リコン", "zh": "侦察"}},
|
||||
{"source": "one tap", "targets": {"ko": "원탭", "ja": "ワンタップ", "zh": "一枪"}},
|
||||
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
|
||||
{"source": "entry", "targets": {"ko": "진입", "ja": "エントリー", "zh": "突破"}},
|
||||
{"source": "entry frag", "targets": {"ko": "선취점", "ja": "エントリーキル", "zh": "首杀"}},
|
||||
{"source": "trade", "targets": {"ko": "트레이드", "ja": "トレード", "zh": "换人"}},
|
||||
{"source": "peek", "targets": {"ko": "픽", "ja": "ピーク", "zh": "架点"}},
|
||||
{"source": "wide peek", "targets": {"ko": "와이드 픽", "ja": "ワイドピーク", "zh": "大幅架点"}},
|
||||
{"source": "clear", "targets": {"ko": "클리어", "ja": "クリア", "zh": "清点"}},
|
||||
{"source": "rush", "targets": {"ko": "러쉬", "ja": "ラッシュ", "zh": "强攻"}},
|
||||
{"source": "stack", "targets": {"ko": "몰빵", "ja": "スタック", "zh": "集火"}},
|
||||
{"source": "lurk", "targets": {"ko": "럴커", "ja": "ラーク", "zh": "潜伏"}},
|
||||
{"source": "op", "targets": {"ko": "오페", "ja": "オペ", "zh": "狙"}},
|
||||
{"source": "operator", "targets": {"ko": "오퍼레이터", "ja": "オペレーター", "zh": "狙击枪"}},
|
||||
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "残局"}},
|
||||
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "一穿五"}},
|
||||
{"source": "spray", "targets": {"ko": "연사", "ja": "スプレー", "zh": "扫射"}},
|
||||
{"source": "crosshair", "targets": {"ko": "크로스헤어", "ja": "クロスヘア", "zh": "准星"}},
|
||||
{"source": "callout", "targets": {"ko": "콜", "ja": "コール", "zh": "报点"}},
|
||||
{"source": "spotted", "targets": {"ko": "봤다", "ja": "発見", "zh": "看到了"}},
|
||||
{"source": "one down", "targets": {"ko": "한 명 잡음", "ja": "一人ダウン", "zh": "倒一个"}},
|
||||
{"source": "man advantage", "targets": {"ko": "인원 우위", "ja": "人数有利", "zh": "人数优势"}},
|
||||
{"source": "low", "targets": {"ko": "피 없음", "ja": "体力低い", "zh": "残血"}},
|
||||
{"source": "armor", "targets": {"ko": "방어구", "ja": "アーマー", "zh": "护甲"}},
|
||||
{"source": "creds", "targets": {"ko": "크레딧", "ja": "クレジット", "zh": "钱"}},
|
||||
{"source": "drop", "targets": {"ko": "드랍", "ja": "ドロップ", "zh": "丢枪"}},
|
||||
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
|
||||
{"source": "match point", "targets": {"ko": "매치포인트", "ja": "マッチポイント", "zh": "赛点"}}
|
||||
]
|
||||
}
|
||||
6
src/livesub/ui/__init__.py
Normal file
6
src/livesub/ui/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Qt UI 레이어."""
|
||||
|
||||
from .main_window import MainWindow
|
||||
from .overlay import SubtitleOverlay, SubtitlePreview
|
||||
|
||||
__all__ = ["MainWindow", "SubtitleOverlay", "SubtitlePreview"]
|
||||
173
src/livesub/ui/hotkeys.py
Normal file
173
src/livesub/ui/hotkeys.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""전역 단축키.
|
||||
|
||||
Qt 의 QShortcut 은 우리 창이 포커스를 가졌을 때만 동작한다. 게임을 하는 중에
|
||||
자막을 껐다 켜야 하므로 OS 레벨 단축키가 필요하다. Windows 에서는
|
||||
`RegisterHotKey` + 네이티브 이벤트 필터로 구현하고, 다른 OS 에서는 조용히
|
||||
아무 것도 하지 않는다(창이 떠 있을 때만 쓰는 QShortcut 은 상위에서 따로 건다).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtCore import QAbstractNativeEventFilter, QObject
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WM_HOTKEY = 0x0312
|
||||
|
||||
MOD_ALT = 0x0001
|
||||
MOD_CONTROL = 0x0002
|
||||
MOD_SHIFT = 0x0004
|
||||
MOD_WIN = 0x0008
|
||||
MOD_NOREPEAT = 0x4000
|
||||
|
||||
_MODIFIERS = {
|
||||
"ctrl": MOD_CONTROL,
|
||||
"control": MOD_CONTROL,
|
||||
"alt": MOD_ALT,
|
||||
"shift": MOD_SHIFT,
|
||||
"win": MOD_WIN,
|
||||
"meta": MOD_WIN,
|
||||
}
|
||||
|
||||
#: 문자/숫자가 아닌 키의 가상 키 코드
|
||||
_NAMED_KEYS = {
|
||||
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
|
||||
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
|
||||
"space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
|
||||
"escape": 0x1B, "insert": 0x2D, "delete": 0x2E, "home": 0x24, "end": 0x23,
|
||||
"pageup": 0x21, "pagedown": 0x22, "up": 0x26, "down": 0x28,
|
||||
"left": 0x25, "right": 0x27, "`": 0xC0, "-": 0xBD, "=": 0xBB,
|
||||
}
|
||||
|
||||
|
||||
class HotkeyError(RuntimeError):
|
||||
"""단축키를 등록할 수 없을 때 (대개 다른 프로그램이 선점)."""
|
||||
|
||||
|
||||
def parse(sequence: str) -> tuple[int, int]:
|
||||
""""Ctrl+Alt+S" → (수정자 비트마스크, 가상 키 코드)."""
|
||||
parts = [p.strip().lower() for p in sequence.split("+") if p.strip()]
|
||||
if not parts:
|
||||
raise HotkeyError(f"빈 단축키입니다: {sequence!r}")
|
||||
|
||||
mods = 0
|
||||
key_part = ""
|
||||
for part in parts:
|
||||
if part in _MODIFIERS:
|
||||
mods |= _MODIFIERS[part]
|
||||
else:
|
||||
key_part = part
|
||||
|
||||
if not key_part:
|
||||
raise HotkeyError(f"조합키만 있고 실제 키가 없습니다: {sequence!r}")
|
||||
if not mods:
|
||||
# 수정자 없는 전역 단축키는 게임 입력을 통째로 가로채므로 막는다.
|
||||
raise HotkeyError(f"Ctrl/Alt/Shift 중 하나는 있어야 합니다: {sequence!r}")
|
||||
|
||||
if key_part in _NAMED_KEYS:
|
||||
vk = _NAMED_KEYS[key_part]
|
||||
elif len(key_part) == 1 and key_part.isalnum():
|
||||
vk = ord(key_part.upper())
|
||||
else:
|
||||
raise HotkeyError(f"알 수 없는 키입니다: {key_part!r}")
|
||||
|
||||
return mods | MOD_NOREPEAT, vk
|
||||
|
||||
|
||||
class GlobalHotkeys(QObject, QAbstractNativeEventFilter):
|
||||
"""전역 단축키 묶음. Windows 전용이며 그 외에서는 no-op."""
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
QObject.__init__(self, parent)
|
||||
QAbstractNativeEventFilter.__init__(self)
|
||||
self._callbacks: dict[int, Callable[[], None]] = {}
|
||||
self._next_id = 1
|
||||
self._installed = False
|
||||
self._last_error = ""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
return self._last_error
|
||||
|
||||
def register(self, sequence: str, callback: Callable[[], None]) -> bool:
|
||||
"""단축키 하나를 등록한다. 성공하면 True."""
|
||||
if not self.supported:
|
||||
self._last_error = "전역 단축키는 Windows에서만 지원합니다."
|
||||
return False
|
||||
try:
|
||||
mods, vk = parse(sequence)
|
||||
except HotkeyError as exc:
|
||||
self._last_error = str(exc)
|
||||
log.warning("단축키 해석 실패: %s", exc)
|
||||
return False
|
||||
|
||||
import ctypes
|
||||
|
||||
hotkey_id = self._next_id
|
||||
if not ctypes.windll.user32.RegisterHotKey(None, hotkey_id, mods, vk):
|
||||
self._last_error = (
|
||||
f"'{sequence}' 를 등록하지 못했습니다. 다른 프로그램이 쓰고 있을 수 있습니다."
|
||||
)
|
||||
log.warning(self._last_error)
|
||||
return False
|
||||
|
||||
self._callbacks[hotkey_id] = callback
|
||||
self._next_id += 1
|
||||
self._ensure_filter()
|
||||
log.info("전역 단축키 등록: %s", sequence)
|
||||
return True
|
||||
|
||||
def unregister_all(self) -> None:
|
||||
if not self.supported or not self._callbacks:
|
||||
return
|
||||
import ctypes
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
for hotkey_id in self._callbacks:
|
||||
ctypes.windll.user32.UnregisterHotKey(None, hotkey_id)
|
||||
self._callbacks.clear()
|
||||
|
||||
if self._installed:
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.removeNativeEventFilter(self)
|
||||
self._installed = False
|
||||
|
||||
def _ensure_filter(self) -> None:
|
||||
if self._installed:
|
||||
return
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installNativeEventFilter(self)
|
||||
self._installed = True
|
||||
|
||||
def nativeEventFilter(self, event_type, message): # noqa: N802 - Qt 규약
|
||||
if not self._callbacks or event_type not in (b"windows_generic_MSG",
|
||||
b"windows_dispatcher_MSG"):
|
||||
return False, 0
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
|
||||
if msg.message != WM_HOTKEY:
|
||||
return False, 0
|
||||
|
||||
callback = self._callbacks.get(int(msg.wParam))
|
||||
if callback is None:
|
||||
return False, 0
|
||||
try:
|
||||
callback()
|
||||
except Exception: # noqa: BLE001 - 콜백 오류로 이벤트 루프가 죽으면 안 된다
|
||||
log.exception("단축키 처리 실패")
|
||||
return True, 0
|
||||
327
src/livesub/ui/main_window.py
Normal file
327
src/livesub/ui/main_window.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
||||
|
||||
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
||||
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import AppConfig
|
||||
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
|
||||
from .hotkeys import GlobalHotkeys
|
||||
from .overlay import SubtitleOverlay
|
||||
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
|
||||
from .theme import SPACING, palette, stylesheet
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NAV = [
|
||||
("홈", "실시간 번역"),
|
||||
("모델", "품질 티어"),
|
||||
("자막", "표시 설정"),
|
||||
("용어집", "고유명사 고정"),
|
||||
("설정", "세부 조정"),
|
||||
]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
|
||||
line_received = Signal(object)
|
||||
status_received = Signal(object)
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
|
||||
self.resize(1060, 820)
|
||||
|
||||
self.engine = TranslationEngine(
|
||||
config,
|
||||
on_line=self.line_received.emit,
|
||||
on_status=self.status_received.emit,
|
||||
)
|
||||
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
|
||||
self.hotkeys = GlobalHotkeys(self)
|
||||
self._transcript_file = None
|
||||
|
||||
self._build_ui()
|
||||
self._connect()
|
||||
self._install_hotkeys()
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
|
||||
self._save_timer.timeout.connect(lambda: self.config.save())
|
||||
|
||||
# --- UI -------------------------------------------------------------
|
||||
def _build_ui(self) -> None:
|
||||
self.setStyleSheet(stylesheet(self.config.theme))
|
||||
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self._build_sidebar())
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self.home_page = HomePage(self.config)
|
||||
self.models_page = ModelsPage(self.config)
|
||||
self.subtitle_page = SubtitlePage(self.config)
|
||||
self.glossary_page = GlossaryPage(self.config)
|
||||
self.settings_page = SettingsPage(self.config)
|
||||
for page in (
|
||||
self.home_page,
|
||||
self.models_page,
|
||||
self.subtitle_page,
|
||||
self.glossary_page,
|
||||
self.settings_page,
|
||||
):
|
||||
self.stack.addWidget(_scrollable(page))
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
def _build_sidebar(self) -> QFrame:
|
||||
p = palette(self.config.theme)
|
||||
bar = QFrame()
|
||||
bar.setObjectName("Sidebar")
|
||||
bar.setFixedWidth(212)
|
||||
|
||||
layout = QVBoxLayout(bar)
|
||||
layout.setContentsMargins(14, 22, 14, 18)
|
||||
layout.setSpacing(6)
|
||||
|
||||
brand = QLabel(APP_NAME)
|
||||
brand.setStyleSheet(
|
||||
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
|
||||
)
|
||||
layout.addWidget(brand)
|
||||
tag = QLabel(APP_SLOGAN)
|
||||
tag.setObjectName("Hint")
|
||||
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
|
||||
layout.addWidget(tag)
|
||||
layout.addSpacing(SPACING + 8)
|
||||
|
||||
self._nav_group = QButtonGroup(self)
|
||||
self._nav_group.setExclusive(True)
|
||||
for index, (title, hint) in enumerate(NAV):
|
||||
button = QPushButton(title)
|
||||
button.setObjectName("NavButton")
|
||||
button.setCheckable(True)
|
||||
button.setToolTip(hint)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._nav_group.addButton(button, index)
|
||||
layout.addWidget(button)
|
||||
self._nav_group.button(0).setChecked(True)
|
||||
self._nav_group.idClicked.connect(self._goto_page)
|
||||
|
||||
layout.addStretch(1)
|
||||
version = QLabel(f"v{APP_VERSION}")
|
||||
version.setObjectName("Hint")
|
||||
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version)
|
||||
return bar
|
||||
|
||||
def _connect(self) -> None:
|
||||
self.line_received.connect(self._on_line)
|
||||
self.status_received.connect(self._on_status)
|
||||
|
||||
self.home_page.start_requested.connect(self.start_engine)
|
||||
self.home_page.stop_requested.connect(self.stop_engine)
|
||||
self.home_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.models_page.tier_changed.connect(self._on_tier_changed)
|
||||
self.models_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.subtitle_page.style_changed.connect(self._on_style_changed)
|
||||
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
|
||||
self.subtitle_page.placement_changed.connect(self._on_placement_changed)
|
||||
|
||||
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
|
||||
|
||||
self.settings_page.config_changed.connect(self._schedule_save)
|
||||
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
||||
|
||||
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
||||
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
||||
self.overlay.closed.connect(
|
||||
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||
)
|
||||
|
||||
# --- 전역 단축키 ------------------------------------------------------
|
||||
def _install_hotkeys(self) -> None:
|
||||
"""설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다."""
|
||||
self.hotkeys.unregister_all()
|
||||
keys = self.config.hotkeys
|
||||
if not keys.enabled:
|
||||
self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.")
|
||||
return
|
||||
if not self.hotkeys.supported:
|
||||
self.settings_page.set_hotkey_status(
|
||||
"전역 단축키는 Windows에서만 동작합니다. "
|
||||
"이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.",
|
||||
ok=False,
|
||||
)
|
||||
return
|
||||
|
||||
registered, failed = [], []
|
||||
for label, sequence, callback in (
|
||||
("자막", keys.toggle_subtitle, self.toggle_subtitle),
|
||||
("번역", keys.toggle_engine, self.toggle_engine),
|
||||
):
|
||||
if not sequence:
|
||||
continue
|
||||
if self.hotkeys.register(sequence, callback):
|
||||
registered.append(f"{label} {sequence}")
|
||||
else:
|
||||
failed.append(f"{label} {sequence}")
|
||||
|
||||
if failed:
|
||||
self.settings_page.set_hotkey_status(
|
||||
f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False
|
||||
)
|
||||
else:
|
||||
self.settings_page.set_hotkey_status(
|
||||
f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다."
|
||||
)
|
||||
|
||||
def toggle_subtitle(self) -> None:
|
||||
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
||||
visible = self.overlay.toggle_subtitle()
|
||||
self._schedule_save()
|
||||
log.info("자막 %s", "켬" if visible else "끔")
|
||||
|
||||
def toggle_engine(self) -> None:
|
||||
if self.engine.running:
|
||||
self.stop_engine()
|
||||
else:
|
||||
self.start_engine()
|
||||
|
||||
# --- 엔진 -----------------------------------------------------------
|
||||
def start_engine(self) -> None:
|
||||
if self.config.overlay.visible:
|
||||
self.overlay.show()
|
||||
self.config.save()
|
||||
self.engine.start()
|
||||
|
||||
def stop_engine(self) -> None:
|
||||
self.engine.stop()
|
||||
self.overlay.clear()
|
||||
self._close_transcript()
|
||||
|
||||
def _on_tier_changed(self, tier_key: str) -> None:
|
||||
was_running = self.engine.running
|
||||
if was_running:
|
||||
self.engine.stop()
|
||||
self.engine.models.switch_tier(tier_key)
|
||||
if was_running:
|
||||
self.engine.start()
|
||||
|
||||
def _on_restart_needed(self) -> None:
|
||||
if self.engine.running:
|
||||
self.engine.stop()
|
||||
self.engine.start()
|
||||
|
||||
# --- 이벤트 ---------------------------------------------------------
|
||||
def _on_line(self, line: TranslationLine) -> None:
|
||||
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
|
||||
self.home_page.on_line(line)
|
||||
if line.is_final and self.config.log_transcripts:
|
||||
self._write_transcript(line)
|
||||
|
||||
def _on_status(self, status: EngineStatus) -> None:
|
||||
self.home_page.on_status(status)
|
||||
if status.state is EngineState.ERROR:
|
||||
QMessageBox.warning(self, "오류", status.message)
|
||||
|
||||
def _on_style_changed(self) -> None:
|
||||
self.overlay.update()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_toggled(self, visible: bool) -> None:
|
||||
if visible == self.config.overlay.visible:
|
||||
return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단
|
||||
self.overlay.set_subtitle_visible(visible)
|
||||
self._schedule_save()
|
||||
|
||||
def _on_placement_changed(self) -> None:
|
||||
"""모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영."""
|
||||
self.overlay.apply_config()
|
||||
self.overlay.snap_to_anchor()
|
||||
self.subtitle_page.sync_from_config()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_moved(self, *_) -> None:
|
||||
"""드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다."""
|
||||
self.subtitle_page.sync_from_config()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_glossary_changed(self) -> None:
|
||||
self.engine.reload_glossary()
|
||||
self._schedule_save()
|
||||
|
||||
def _goto_page(self, index: int) -> None:
|
||||
self.stack.setCurrentIndex(index)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
self._save_timer.start()
|
||||
|
||||
# --- 기록 -----------------------------------------------------------
|
||||
def _write_transcript(self, line: TranslationLine) -> None:
|
||||
if self._transcript_file is None:
|
||||
folder = user_data_dir() / "transcripts"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
|
||||
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
|
||||
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
self._transcript_file.write(
|
||||
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
|
||||
)
|
||||
self._transcript_file.flush()
|
||||
|
||||
def _close_transcript(self) -> None:
|
||||
if self._transcript_file is not None:
|
||||
self._transcript_file.close()
|
||||
self._transcript_file = None
|
||||
|
||||
# --- 종료 -----------------------------------------------------------
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.config.save()
|
||||
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
|
||||
self.engine.shutdown()
|
||||
self._close_transcript()
|
||||
self.overlay.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _scrollable(widget: QWidget) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
holder = QWidget()
|
||||
layout = QVBoxLayout(holder)
|
||||
layout.setContentsMargins(26, 24, 26, 24)
|
||||
layout.addWidget(widget)
|
||||
area.setWidget(holder)
|
||||
return area
|
||||
385
src/livesub/ui/overlay.py
Normal file
385
src/livesub/ui/overlay.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""자막 오버레이 창.
|
||||
|
||||
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
||||
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
||||
|
||||
조작:
|
||||
* 드래그 — 위치 이동
|
||||
* 우하단 모서리 — 크기 조절
|
||||
* 휠 — 글자 크기 조절
|
||||
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontMetrics,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
)
|
||||
from PySide6.QtWidgets import QMenu, QWidget
|
||||
|
||||
from ..config import OverlayConfig, SubtitleStyle
|
||||
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
|
||||
|
||||
GRIP = 18 # 우하단 리사이즈 핸들 크기
|
||||
|
||||
|
||||
def paint_subtitle(
|
||||
painter: QPainter,
|
||||
rect: QRect,
|
||||
st: SubtitleStyle,
|
||||
translated: str,
|
||||
source: str = "",
|
||||
partial: str = "",
|
||||
) -> None:
|
||||
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||
|
||||
if st.background_opacity > 0:
|
||||
bg = QColor(st.background_color)
|
||||
bg.setAlpha(int(255 * st.background_opacity / 100))
|
||||
painter.setBrush(bg)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(rect, 12, 12)
|
||||
|
||||
rows: list[tuple[str, QColor, int]] = []
|
||||
if st.show_source and source and translated:
|
||||
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
||||
main = translated or partial
|
||||
if main:
|
||||
color = QColor(st.text_color)
|
||||
if not translated: # 아직 확정 전 — 흐리게
|
||||
color.setAlpha(170)
|
||||
rows.append((main, color, st.font_size))
|
||||
if not rows:
|
||||
return
|
||||
|
||||
margin = 18
|
||||
available = rect.width() - margin * 2
|
||||
blocks: list[tuple[list[str], QColor, QFont]] = []
|
||||
total_h = 0
|
||||
for text, color, size in rows:
|
||||
font = QFont(st.font_family, size)
|
||||
font.setBold(st.bold)
|
||||
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
|
||||
blocks.append((lines, color, font))
|
||||
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
|
||||
|
||||
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
|
||||
for lines, color, font in blocks:
|
||||
painter.setFont(font)
|
||||
fm = QFontMetrics(font)
|
||||
for line in lines:
|
||||
width = fm.horizontalAdvance(line)
|
||||
if st.align == "left":
|
||||
x = rect.x() + margin
|
||||
elif st.align == "right":
|
||||
x = rect.right() - margin - width
|
||||
else:
|
||||
x = rect.x() + (rect.width() - width) // 2
|
||||
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
||||
y += fm.height() + st.line_spacing
|
||||
|
||||
|
||||
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
||||
"""외곽선 있는 글자.
|
||||
|
||||
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
|
||||
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
|
||||
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
|
||||
"""
|
||||
if st.outline_width <= 0:
|
||||
painter.setPen(color)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawText(x, y, text)
|
||||
return
|
||||
|
||||
path = QPainterPath()
|
||||
path.addText(x, y, font, text)
|
||||
|
||||
pen = QPen(QColor(st.outline_color))
|
||||
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(color)
|
||||
painter.drawPath(path)
|
||||
|
||||
|
||||
class SubtitlePreview(QWidget):
|
||||
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
|
||||
|
||||
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.style_cfg = style
|
||||
self.translated = ""
|
||||
self.source = ""
|
||||
self.setMinimumHeight(140)
|
||||
|
||||
def set_sample(self, translated: str, source: str) -> None:
|
||||
self.translated = translated
|
||||
self.source = source
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802
|
||||
painter = QPainter(self)
|
||||
paint_subtitle(
|
||||
painter, self.rect(), self.style_cfg, self.translated, self.source
|
||||
)
|
||||
|
||||
|
||||
class SubtitleOverlay(QWidget):
|
||||
"""항상 위에 떠 있는 자막 창."""
|
||||
|
||||
geometry_changed = Signal(int, int, int, int)
|
||||
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
||||
visibility_changed = Signal(bool)
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
||||
super().__init__(None)
|
||||
self.style_cfg = style
|
||||
self.overlay_cfg = overlay
|
||||
|
||||
self._final_text = ""
|
||||
self._partial_text = ""
|
||||
self._source_text = ""
|
||||
self._drag_from: QPoint | None = None
|
||||
self._resize_from: QRect | None = None
|
||||
|
||||
self._fade = QTimer(self)
|
||||
self._fade.setSingleShot(True)
|
||||
self._fade.timeout.connect(self._clear_text)
|
||||
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.Tool
|
||||
| Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
self.setMinimumSize(320, 90)
|
||||
self.apply_config()
|
||||
|
||||
# --- 외부 API -------------------------------------------------------
|
||||
def apply_config(self) -> None:
|
||||
cfg = self.overlay_cfg
|
||||
flags = self.windowFlags()
|
||||
flags = (
|
||||
flags | Qt.WindowType.WindowStaysOnTopHint
|
||||
if cfg.always_on_top
|
||||
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
self.setWindowFlags(flags)
|
||||
self.setAttribute(
|
||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
|
||||
)
|
||||
|
||||
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
||||
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
|
||||
self.snap_to_anchor(save_only=True)
|
||||
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||
self.setVisible(cfg.visible)
|
||||
self.update()
|
||||
|
||||
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
||||
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
||||
cfg = self.overlay_cfg
|
||||
if anchor is not None:
|
||||
cfg.anchor = anchor
|
||||
if cfg.anchor == ANCHOR_CUSTOM:
|
||||
return
|
||||
|
||||
screens = list_screens()
|
||||
screen = pick_screen(screens, cfg.screen_index)
|
||||
if screen is None:
|
||||
return
|
||||
cfg.screen_index = screen.index
|
||||
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
|
||||
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
|
||||
)
|
||||
if not save_only:
|
||||
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
|
||||
|
||||
def set_subtitle_visible(self, visible: bool) -> None:
|
||||
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
||||
self.overlay_cfg.visible = visible
|
||||
self.setVisible(visible)
|
||||
if not visible:
|
||||
self._clear_text()
|
||||
self.visibility_changed.emit(visible)
|
||||
|
||||
def toggle_subtitle(self) -> bool:
|
||||
self.set_subtitle_visible(not self.overlay_cfg.visible)
|
||||
return self.overlay_cfg.visible
|
||||
|
||||
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
|
||||
if is_final:
|
||||
self._final_text = translated or source
|
||||
self._partial_text = ""
|
||||
self._source_text = source
|
||||
else:
|
||||
self._partial_text = source
|
||||
self._restart_fade()
|
||||
self.update()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._clear_text()
|
||||
|
||||
# --- 그리기 ---------------------------------------------------------
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
|
||||
painter = QPainter(self)
|
||||
paint_subtitle(
|
||||
painter,
|
||||
self.rect(),
|
||||
self.style_cfg,
|
||||
self._final_text,
|
||||
self._source_text,
|
||||
self._partial_text,
|
||||
)
|
||||
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
|
||||
self._draw_placeholder(painter)
|
||||
self._draw_grip(painter)
|
||||
|
||||
def _draw_placeholder(self, painter) -> None:
|
||||
painter.setPen(QColor(255, 255, 255, 90))
|
||||
font = QFont(self.style_cfg.font_family, 13)
|
||||
painter.setFont(font)
|
||||
painter.drawText(
|
||||
self.rect(),
|
||||
Qt.AlignmentFlag.AlignCenter,
|
||||
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
|
||||
)
|
||||
|
||||
def _draw_grip(self, painter) -> None:
|
||||
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
|
||||
return
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
|
||||
w, h = self.width(), self.height()
|
||||
for offset in (5, 10, 15):
|
||||
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
|
||||
|
||||
# --- 마우스 ---------------------------------------------------------
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
pos = event.position().toPoint()
|
||||
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
|
||||
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
|
||||
else:
|
||||
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
||||
|
||||
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
||||
if self._resize_from is not None:
|
||||
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
|
||||
self.resize(
|
||||
max(self.minimumWidth(), self._resize_from.width() + delta.x()),
|
||||
max(self.minimumHeight(), self._resize_from.height() + delta.y()),
|
||||
)
|
||||
elif self._drag_from is not None:
|
||||
self.move(event.globalPosition().toPoint() - self._drag_from)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
||||
if self._drag_from is None and self._resize_from is None:
|
||||
return
|
||||
self._drag_from = None
|
||||
self._resize_from = None
|
||||
|
||||
cfg = self.overlay_cfg
|
||||
g = self.geometry()
|
||||
cfg.x, cfg.y = g.x(), g.y()
|
||||
cfg.width, cfg.height = g.width(), g.height()
|
||||
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
|
||||
cfg.anchor = ANCHOR_CUSTOM
|
||||
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
|
||||
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
|
||||
if owner is not None:
|
||||
cfg.screen_index = owner.index
|
||||
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
||||
|
||||
def wheelEvent(self, event) -> None: # noqa: N802
|
||||
if self.overlay_cfg.locked:
|
||||
return
|
||||
step = 2 if event.angleDelta().y() > 0 else -2
|
||||
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
|
||||
self.update()
|
||||
|
||||
def contextMenuEvent(self, event) -> None: # noqa: N802
|
||||
menu = QMenu(self)
|
||||
cfg = self.overlay_cfg
|
||||
|
||||
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
|
||||
lock.toggled.connect(self._set_locked)
|
||||
menu.addAction(lock)
|
||||
|
||||
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
|
||||
through.toggled.connect(self._set_click_through)
|
||||
menu.addAction(through)
|
||||
|
||||
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
|
||||
top.toggled.connect(self._set_always_on_top)
|
||||
menu.addAction(top)
|
||||
|
||||
menu.addSeparator()
|
||||
menu.addAction("자막 지우기", self.clear)
|
||||
menu.addAction("자막 창 숨기기", self.hide_overlay)
|
||||
menu.exec(event.globalPos())
|
||||
|
||||
# --- 메뉴 동작 ------------------------------------------------------
|
||||
def _set_locked(self, value: bool) -> None:
|
||||
self.overlay_cfg.locked = value
|
||||
self.update()
|
||||
|
||||
def _set_click_through(self, value: bool) -> None:
|
||||
self.overlay_cfg.click_through = value
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
|
||||
self.update()
|
||||
|
||||
def _set_always_on_top(self, value: bool) -> None:
|
||||
self.overlay_cfg.always_on_top = value
|
||||
self.apply_config()
|
||||
|
||||
def hide_overlay(self) -> None:
|
||||
self.set_subtitle_visible(False)
|
||||
self.closed.emit()
|
||||
|
||||
def _restart_fade(self) -> None:
|
||||
self._fade.stop()
|
||||
if self.style_cfg.fade_out_ms > 0:
|
||||
self._fade.start(self.style_cfg.fade_out_ms)
|
||||
|
||||
def _clear_text(self) -> None:
|
||||
self._final_text = ""
|
||||
self._partial_text = ""
|
||||
self._source_text = ""
|
||||
self.update()
|
||||
|
||||
|
||||
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
|
||||
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
|
||||
if width <= 0 or not text:
|
||||
return [text] if text else []
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in text.split():
|
||||
candidate = f"{current} {word}".strip()
|
||||
if fm.horizontalAdvance(candidate) <= width or not current:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
|
||||
return lines[-max_lines:] if max_lines > 0 else lines
|
||||
7
src/livesub/ui/pages/__init__.py
Normal file
7
src/livesub/ui/pages/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .glossary_page import GlossaryPage
|
||||
from .home import HomePage
|
||||
from .models_page import ModelsPage
|
||||
from .settings_page import SettingsPage
|
||||
from .subtitle_page import SubtitlePage
|
||||
|
||||
__all__ = ["GlossaryPage", "HomePage", "ModelsPage", "SettingsPage", "SubtitlePage"]
|
||||
297
src/livesub/ui/pages/glossary_page.py
Normal file
297
src/livesub/ui/pages/glossary_page.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""용어집 — 게임·방송 고유명사를 원하는 번역으로 고정한다."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
|
||||
from ...models import Glossary, GlossaryEntry
|
||||
from ...models.packs import available_packs
|
||||
from ..theme import SPACING
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
COLUMNS = ["원문 용어", *[f"{LANGUAGES[c]['label']} 역어" for c in LANGUAGE_CODES], "메모"]
|
||||
|
||||
|
||||
class GlossaryPage(QWidget):
|
||||
glossary_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.path = Path(config.glossary.path or user_data_dir() / "glossary.json")
|
||||
self.glossary = Glossary.load(self.path, config.glossary.case_sensitive)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader(
|
||||
"용어집",
|
||||
"여기 등록한 단어는 번역 모델이 마음대로 바꾸지 못하고 지정한 역어로 고정됩니다. "
|
||||
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
|
||||
)
|
||||
)
|
||||
root.addWidget(self._build_packs_card())
|
||||
root.addWidget(self._build_options_card())
|
||||
root.addWidget(self._build_table_card(), 1)
|
||||
self._reload_table()
|
||||
|
||||
# --- 기본 제공 팩 ----------------------------------------------------
|
||||
def _build_packs_card(self) -> Card:
|
||||
card = Card(
|
||||
"게임별 기본 용어집",
|
||||
"체크만 하면 바로 적용됩니다. 역어가 마음에 안 들면 아래 표에 같은 "
|
||||
"단어를 등록하세요 — 직접 등록한 쪽이 항상 이깁니다.",
|
||||
)
|
||||
self._pack_checks: dict[str, QCheckBox] = {}
|
||||
enabled = set(self.config.glossary.enabled_packs)
|
||||
|
||||
packs = available_packs()
|
||||
if not packs:
|
||||
card.add(_hint("기본 제공 용어집을 찾지 못했습니다."))
|
||||
return card
|
||||
|
||||
for pack in packs:
|
||||
check = QCheckBox(f"{pack.name} ({len(pack)}개)")
|
||||
check.setChecked(pack.key in enabled)
|
||||
check.setToolTip(pack.description)
|
||||
check.toggled.connect(self._on_packs_changed)
|
||||
self._pack_checks[pack.key] = check
|
||||
card.add(check)
|
||||
|
||||
self.pack_total = card.add(_hint(""))
|
||||
self._update_pack_total()
|
||||
return card
|
||||
|
||||
def _on_packs_changed(self, *_) -> None:
|
||||
self.config.glossary.enabled_packs = [
|
||||
key for key, check in self._pack_checks.items() if check.isChecked()
|
||||
]
|
||||
self._update_pack_total()
|
||||
self.glossary_changed.emit()
|
||||
|
||||
def _update_pack_total(self) -> None:
|
||||
enabled = set(self.config.glossary.enabled_packs)
|
||||
total = sum(len(p) for p in available_packs() if p.key in enabled)
|
||||
self.pack_total.setText(f"켜둔 기본 용어 {total}개")
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_options_card(self) -> Card:
|
||||
card = Card()
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.enabled_check = QCheckBox("용어집 사용")
|
||||
self.enabled_check.setChecked(self.config.glossary.enabled)
|
||||
self.enabled_check.toggled.connect(self._on_enabled)
|
||||
layout.addWidget(self.enabled_check)
|
||||
|
||||
self.case_check = QCheckBox("대소문자 구분")
|
||||
self.case_check.setChecked(self.config.glossary.case_sensitive)
|
||||
self.case_check.toggled.connect(self._on_case)
|
||||
layout.addWidget(self.case_check)
|
||||
layout.addStretch(1)
|
||||
|
||||
for label, slot in (
|
||||
("CSV 가져오기", self._import_csv),
|
||||
("CSV 내보내기", self._export_csv),
|
||||
):
|
||||
button = QPushButton(label)
|
||||
button.clicked.connect(slot)
|
||||
layout.addWidget(button)
|
||||
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
def _build_table_card(self) -> Card:
|
||||
card = Card(
|
||||
"등록된 용어",
|
||||
"역어를 비워두면 그 언어에서는 규칙을 적용하지 않습니다. "
|
||||
"CSV 형식: 원문,한국어,English,日本語,中文,메모",
|
||||
)
|
||||
|
||||
search_row = QWidget()
|
||||
search_layout = QHBoxLayout(search_row)
|
||||
search_layout.setContentsMargins(0, 0, 0, 0)
|
||||
search_layout.setSpacing(10)
|
||||
self.search = QLineEdit()
|
||||
self.search.setPlaceholderText("용어 검색…")
|
||||
self.search.textChanged.connect(self._filter)
|
||||
search_layout.addWidget(self.search, 1)
|
||||
for label, slot in (("행 추가", self._add_row), ("선택 삭제", self._delete_rows)):
|
||||
button = QPushButton(label)
|
||||
button.clicked.connect(slot)
|
||||
search_layout.addWidget(button)
|
||||
card.add(search_row)
|
||||
|
||||
self.table = QTableWidget(0, len(COLUMNS))
|
||||
self.table.setHorizontalHeaderLabels(COLUMNS)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch
|
||||
)
|
||||
self.table.itemChanged.connect(self._on_item_changed)
|
||||
card.add(self.table)
|
||||
|
||||
self.count_label = card.add(
|
||||
_hint(f"{len(self.glossary)}개 등록됨")
|
||||
)
|
||||
return card
|
||||
|
||||
# --- 데이터 ---------------------------------------------------------
|
||||
def _reload_table(self) -> None:
|
||||
self.table.blockSignals(True)
|
||||
self.table.setRowCount(0)
|
||||
for entry in self.glossary.entries:
|
||||
self._append_row(entry)
|
||||
self.table.blockSignals(False)
|
||||
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
|
||||
|
||||
def _append_row(self, entry: GlossaryEntry) -> None:
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
values = [entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
|
||||
for col, value in enumerate(values):
|
||||
self.table.setItem(row, col, QTableWidgetItem(value))
|
||||
|
||||
def _collect(self) -> list[GlossaryEntry]:
|
||||
entries: list[GlossaryEntry] = []
|
||||
for row in range(self.table.rowCount()):
|
||||
source_item = self.table.item(row, 0)
|
||||
source = source_item.text().strip() if source_item else ""
|
||||
if not source:
|
||||
continue
|
||||
targets: dict[str, str] = {}
|
||||
for i, code in enumerate(LANGUAGE_CODES, start=1):
|
||||
item = self.table.item(row, i)
|
||||
text = item.text().strip() if item else ""
|
||||
if text:
|
||||
targets[code] = text
|
||||
note_item = self.table.item(row, len(COLUMNS) - 1)
|
||||
entries.append(
|
||||
GlossaryEntry(
|
||||
source=source,
|
||||
targets=targets,
|
||||
note=note_item.text().strip() if note_item else "",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
def _save(self) -> None:
|
||||
self.glossary = Glossary(self._collect(), self.config.glossary.case_sensitive)
|
||||
self.glossary.save(self.path)
|
||||
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
|
||||
self.glossary_changed.emit()
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _on_item_changed(self, *_) -> None:
|
||||
self._save()
|
||||
|
||||
def _on_enabled(self, value: bool) -> None:
|
||||
self.config.glossary.enabled = value
|
||||
self.glossary_changed.emit()
|
||||
|
||||
def _on_case(self, value: bool) -> None:
|
||||
self.config.glossary.case_sensitive = value
|
||||
self._save()
|
||||
|
||||
def _add_row(self) -> None:
|
||||
self.table.blockSignals(True)
|
||||
self._append_row(GlossaryEntry(source=""))
|
||||
self.table.blockSignals(False)
|
||||
self.table.editItem(self.table.item(self.table.rowCount() - 1, 0))
|
||||
|
||||
def _delete_rows(self) -> None:
|
||||
rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
|
||||
if not rows:
|
||||
return
|
||||
self.table.blockSignals(True)
|
||||
for row in rows:
|
||||
self.table.removeRow(row)
|
||||
self.table.blockSignals(False)
|
||||
self._save()
|
||||
|
||||
def _filter(self, text: str) -> None:
|
||||
needle = text.strip().casefold()
|
||||
for row in range(self.table.rowCount()):
|
||||
haystack = " ".join(
|
||||
(self.table.item(row, c).text() if self.table.item(row, c) else "")
|
||||
for c in range(self.table.columnCount())
|
||||
).casefold()
|
||||
self.table.setRowHidden(row, bool(needle) and needle not in haystack)
|
||||
|
||||
def _import_csv(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(self, "CSV 가져오기", "", "CSV (*.csv)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, encoding="utf-8-sig", newline="") as fh:
|
||||
rows = [r for r in csv.reader(fh) if r and r[0].strip()]
|
||||
except OSError as exc:
|
||||
QMessageBox.warning(self, "가져오기 실패", str(exc))
|
||||
return
|
||||
|
||||
# 첫 줄이 헤더처럼 보이면 건너뛴다.
|
||||
if rows and rows[0][0].strip() in ("원문", "원문 용어", "source"):
|
||||
rows = rows[1:]
|
||||
|
||||
added = 0
|
||||
for row in rows:
|
||||
cells = [c.strip() for c in row] + [""] * (len(COLUMNS) - len(row))
|
||||
targets = {
|
||||
code: cells[i]
|
||||
for i, code in enumerate(LANGUAGE_CODES, start=1)
|
||||
if cells[i]
|
||||
}
|
||||
self.glossary.add(
|
||||
GlossaryEntry(source=cells[0], targets=targets, note=cells[len(COLUMNS) - 1])
|
||||
)
|
||||
added += 1
|
||||
self.glossary.save(self.path)
|
||||
self._reload_table()
|
||||
self.glossary_changed.emit()
|
||||
QMessageBox.information(self, "가져오기 완료", f"{added}개 용어를 반영했습니다.")
|
||||
|
||||
def _export_csv(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "CSV 내보내기", "glossary.csv", "CSV (*.csv)"
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(COLUMNS)
|
||||
for entry in self.glossary.entries:
|
||||
writer.writerow(
|
||||
[entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
|
||||
)
|
||||
|
||||
|
||||
def _hint(text: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
label.setObjectName("Hint")
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
return label
|
||||
226
src/livesub/ui/pages/home.py
Normal file
226
src/livesub/ui/pages/home.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...audio import capture_capabilities, list_sources
|
||||
from ...config import AppConfig
|
||||
from ...constants import LANGUAGES
|
||||
from ...core.events import EngineState, EngineStatus, TranslationLine
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
|
||||
|
||||
MAX_LOG_ROWS = 300
|
||||
|
||||
|
||||
class HomePage(QWidget):
|
||||
start_requested = Signal()
|
||||
stop_requested = Signal()
|
||||
config_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self._sources: list = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
|
||||
)
|
||||
|
||||
root.addWidget(self._build_source_card())
|
||||
root.addWidget(self._build_control_card())
|
||||
root.addWidget(self._build_log_card(), 1)
|
||||
|
||||
self.refresh_sources()
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_source_card(self) -> Card:
|
||||
card = Card("소리 받아올 곳", "")
|
||||
self.capability_label = QLabel()
|
||||
self.capability_label.setObjectName("Hint")
|
||||
self.capability_label.setWordWrap(True)
|
||||
card.add(self.capability_label)
|
||||
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.source_combo = QComboBox()
|
||||
self.source_combo.setMinimumWidth(360)
|
||||
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||
layout.addWidget(self.source_combo, 1)
|
||||
|
||||
refresh = QPushButton("새로고침")
|
||||
refresh.clicked.connect(self.refresh_sources)
|
||||
layout.addWidget(refresh)
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
def _build_control_card(self) -> Card:
|
||||
card = Card()
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self.source_lang = QComboBox()
|
||||
self.source_lang.addItem("자동 감지", "auto")
|
||||
for code, meta in LANGUAGES.items():
|
||||
self.source_lang.addItem(meta["label"], code)
|
||||
self.source_lang.setCurrentIndex(
|
||||
max(0, self.source_lang.findData(self.config.models.source_lang))
|
||||
)
|
||||
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
|
||||
|
||||
self.target_lang = QComboBox()
|
||||
for code, meta in LANGUAGES.items():
|
||||
self.target_lang.addItem(meta["label"], code)
|
||||
self.target_lang.setCurrentIndex(
|
||||
max(0, self.target_lang.findData(self.config.models.target_lang))
|
||||
)
|
||||
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
|
||||
|
||||
layout.addWidget(QLabel("원본"))
|
||||
layout.addWidget(self.source_lang)
|
||||
arrow = QLabel("→")
|
||||
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
|
||||
layout.addWidget(arrow)
|
||||
layout.addWidget(QLabel("번역"))
|
||||
layout.addWidget(self.target_lang)
|
||||
layout.addStretch(1)
|
||||
|
||||
self.status_pill = StatusPill()
|
||||
layout.addWidget(self.status_pill)
|
||||
|
||||
self.toggle_button = QPushButton("번역 시작")
|
||||
self.toggle_button.setObjectName("Primary")
|
||||
self.toggle_button.setMinimumWidth(140)
|
||||
self.toggle_button.clicked.connect(self._on_toggle)
|
||||
layout.addWidget(self.toggle_button)
|
||||
|
||||
card.add(row)
|
||||
self.level_meter = LevelMeter()
|
||||
card.add(self.level_meter)
|
||||
return card
|
||||
|
||||
def _build_log_card(self) -> Card:
|
||||
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
|
||||
self.log_list = QListWidget()
|
||||
self.log_list.setWordWrap(True)
|
||||
self.log_list.setAlternatingRowColors(False)
|
||||
card.add(self.log_list)
|
||||
return card
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def refresh_sources(self) -> None:
|
||||
caps = capture_capabilities()
|
||||
self._sources = list_sources()
|
||||
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.clear()
|
||||
for src in self._sources:
|
||||
prefix = "🎮" if src.kind == "process" else "🔊"
|
||||
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
|
||||
if not self._sources:
|
||||
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
|
||||
self._select_saved_source()
|
||||
self.source_combo.blockSignals(False)
|
||||
self._on_source_changed()
|
||||
|
||||
if caps["process"]:
|
||||
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
|
||||
elif caps["device"]:
|
||||
msg = (
|
||||
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
|
||||
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
|
||||
"프로그램 단위로 분리됩니다."
|
||||
)
|
||||
else:
|
||||
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
|
||||
self.capability_label.setText(msg)
|
||||
|
||||
def _select_saved_source(self) -> None:
|
||||
cfg = self.config.audio
|
||||
for i, src in enumerate(self._sources):
|
||||
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
|
||||
self.source_combo.setCurrentIndex(i)
|
||||
return
|
||||
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
|
||||
self.source_combo.setCurrentIndex(i)
|
||||
return
|
||||
|
||||
def _on_source_changed(self, *_) -> None:
|
||||
src = self.source_combo.currentData()
|
||||
cfg = self.config.audio
|
||||
if src is None:
|
||||
return
|
||||
if src.kind == "process":
|
||||
cfg.backend = "process"
|
||||
cfg.target_pid = src.pid
|
||||
cfg.target_process_name = src.label
|
||||
else:
|
||||
cfg.backend = "device"
|
||||
cfg.device_index = src.device_index
|
||||
cfg.target_pid = 0
|
||||
self.config_changed.emit()
|
||||
|
||||
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_changed.emit()
|
||||
|
||||
def _on_toggle(self) -> None:
|
||||
if self.toggle_button.property("running"):
|
||||
self.stop_requested.emit()
|
||||
else:
|
||||
self.start_requested.emit()
|
||||
|
||||
# --- 엔진 연동 ------------------------------------------------------
|
||||
def on_status(self, status: EngineStatus) -> None:
|
||||
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
|
||||
self.toggle_button.setProperty("running", running)
|
||||
self.toggle_button.setText("번역 정지" if running else "번역 시작")
|
||||
self.toggle_button.setObjectName("Danger" if running else "Primary")
|
||||
self.toggle_button.style().polish(self.toggle_button)
|
||||
|
||||
kind = {
|
||||
EngineState.RUNNING: "ok",
|
||||
EngineState.LOADING: "busy",
|
||||
EngineState.STOPPING: "busy",
|
||||
EngineState.ERROR: "error",
|
||||
}.get(status.state, "idle")
|
||||
self.status_pill.set_status(status.message or status.state.value, kind)
|
||||
self.level_meter.set_level(status.level)
|
||||
|
||||
for widget in (self.source_combo, self.source_lang, self.target_lang):
|
||||
widget.setEnabled(not running)
|
||||
|
||||
def on_line(self, line: TranslationLine) -> None:
|
||||
if not line.is_final:
|
||||
return
|
||||
item = QListWidgetItem(
|
||||
f"{line.translated_text}\n"
|
||||
f" {line.source_text} · {line.latency_s:.1f}초 "
|
||||
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
|
||||
)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
item.setToolTip(line.source_text)
|
||||
self.log_list.insertItem(0, item)
|
||||
while self.log_list.count() > MAX_LOG_ROWS:
|
||||
self.log_list.takeItem(self.log_list.count() - 1)
|
||||
self.log_list.scrollToTop()
|
||||
221
src/livesub/ui/pages/models_page.py
Normal file
221
src/livesub/ui/pages/models_page.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ...models import Tier, detect_gpu, ordered_tiers
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
|
||||
class TierCard(QFrame):
|
||||
"""티어 하나를 라디오 버튼 카드로."""
|
||||
|
||||
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.tier = tier
|
||||
self.setObjectName("Card")
|
||||
p = palette("dark")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 14, 16, 14)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self.radio = QRadioButton()
|
||||
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
text = QVBoxLayout()
|
||||
text.setSpacing(3)
|
||||
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(8)
|
||||
name = QLabel(f"{tier.order}. {tier.name}")
|
||||
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
||||
head.addWidget(name)
|
||||
if tier.recommended:
|
||||
head.addWidget(_badge("추천", p.accent))
|
||||
if tier.best_after_finetune:
|
||||
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))
|
||||
head.addStretch(1)
|
||||
text.addLayout(head)
|
||||
|
||||
tagline = QLabel(tier.tagline)
|
||||
tagline.setObjectName("Subtitle")
|
||||
text.addWidget(tagline)
|
||||
|
||||
spec = QLabel(
|
||||
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
|
||||
f"· 내려받기 약 {tier.download_gb:g}GB"
|
||||
)
|
||||
spec.setObjectName("Hint")
|
||||
text.addWidget(spec)
|
||||
|
||||
if tier.notes:
|
||||
note = QLabel(tier.notes)
|
||||
note.setObjectName("Hint")
|
||||
note.setWordWrap(True)
|
||||
text.addWidget(note)
|
||||
|
||||
layout.addLayout(text, 1)
|
||||
|
||||
def set_selected(self, selected: bool) -> None:
|
||||
self.setObjectName("CardSelected" if selected else "Card")
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
|
||||
class ModelsPage(QWidget):
|
||||
tier_changed = Signal(str)
|
||||
config_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self._cards: list[TierCard] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader(
|
||||
"모델",
|
||||
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
|
||||
"모델은 처음 사용할 때 자동으로 내려받습니다.",
|
||||
)
|
||||
)
|
||||
|
||||
root.addWidget(self._build_gpu_card())
|
||||
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
for tier in ordered_tiers():
|
||||
card = TierCard(tier, self._gpu_vram_gb)
|
||||
self._group.addButton(card.radio, tier.order)
|
||||
card.radio.toggled.connect(
|
||||
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
||||
)
|
||||
self._cards.append(card)
|
||||
root.addWidget(card)
|
||||
|
||||
root.addWidget(self._build_lora_card())
|
||||
root.addStretch(1)
|
||||
self._apply_selection(config.models.tier)
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_gpu_card(self) -> Card:
|
||||
gpu = detect_gpu()
|
||||
self._gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
||||
p = palette("dark")
|
||||
|
||||
card = Card("실행 환경")
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
if gpu.available:
|
||||
text = (
|
||||
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
|
||||
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
|
||||
)
|
||||
color = p.success
|
||||
else:
|
||||
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
|
||||
color = p.warning
|
||||
info = QLabel(text)
|
||||
info.setStyleSheet(f"color: {color}; font-weight: 600;")
|
||||
info.setWordWrap(True)
|
||||
layout.addWidget(info, 1)
|
||||
|
||||
self.device_combo = QComboBox()
|
||||
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
||||
self.device_combo.addItem("CPU", "cpu")
|
||||
self.device_combo.setCurrentIndex(
|
||||
max(0, self.device_combo.findData(self.config.models.device))
|
||||
)
|
||||
self.device_combo.setEnabled(gpu.available)
|
||||
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
|
||||
layout.addWidget(self.device_combo)
|
||||
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
def _build_lora_card(self) -> Card:
|
||||
card = Card(
|
||||
"추가학습 어댑터 (선택)",
|
||||
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
|
||||
"scripts/finetune_mt.py 로 만들 수 있습니다.",
|
||||
)
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
|
||||
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
|
||||
self.lora_edit.editingFinished.connect(self._on_lora_changed)
|
||||
layout.addWidget(self.lora_edit, 1)
|
||||
|
||||
browse = QPushButton("폴더 선택")
|
||||
browse.clicked.connect(self._browse_lora)
|
||||
layout.addWidget(browse)
|
||||
|
||||
card.add(row)
|
||||
return card
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _apply_selection(self, tier_key: str) -> None:
|
||||
for card in self._cards:
|
||||
selected = card.tier.key == tier_key
|
||||
card.radio.setChecked(selected)
|
||||
card.set_selected(selected)
|
||||
|
||||
def _on_tier_selected(self, tier: Tier) -> None:
|
||||
if self.config.models.tier == tier.key:
|
||||
return
|
||||
self.config.models.tier = tier.key
|
||||
for card in self._cards:
|
||||
card.set_selected(card.tier.key == tier.key)
|
||||
self.tier_changed.emit(tier.key)
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_device_changed(self) -> None:
|
||||
self.config.models.device = self.device_combo.currentData()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_lora_changed(self) -> None:
|
||||
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _browse_lora(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
|
||||
if path:
|
||||
self.lora_edit.setText(path)
|
||||
self._on_lora_changed()
|
||||
|
||||
|
||||
def _badge(text: str, color: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
label.setStyleSheet(
|
||||
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
|
||||
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
|
||||
)
|
||||
return label
|
||||
324
src/livesub/ui/pages/settings_page.py
Normal file
324
src/livesub/ui/pages/settings_page.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||
from ...models import detect_gpu
|
||||
from ..theme import SPACING, palette
|
||||
from ..widgets import Card, PageHeader
|
||||
|
||||
|
||||
class SettingsPage(QWidget):
|
||||
config_changed = Signal()
|
||||
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
|
||||
restart_needed = Signal()
|
||||
#: 전역 단축키를 다시 등록해야 할 때
|
||||
hotkeys_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader("설정", "게임과 같이 돌릴 때의 GPU 부담, 단축키, 인식 민감도를 조절합니다.")
|
||||
)
|
||||
|
||||
root.addWidget(self._build_performance_card())
|
||||
root.addWidget(self._build_hotkey_card())
|
||||
root.addWidget(self._build_audio_card())
|
||||
root.addWidget(self._build_behavior_card())
|
||||
root.addWidget(self._build_about_card())
|
||||
root.addStretch(1)
|
||||
|
||||
# --- GPU 부담 --------------------------------------------------------
|
||||
def _build_performance_card(self) -> Card:
|
||||
perf = self.config.performance
|
||||
card = Card(
|
||||
"게임과 같이 쓰기 (GPU 절약)",
|
||||
"게임이 먼저입니다. 이 모드를 켜면 자막 품질을 조금 내주는 대신 "
|
||||
"게임 프레임과 VRAM을 지킵니다.",
|
||||
)
|
||||
form = card.add_form()
|
||||
|
||||
self.game_mode_check = QCheckBox("저부하 모드 (권장)")
|
||||
self.game_mode_check.setChecked(perf.game_mode)
|
||||
self.game_mode_check.setToolTip(
|
||||
"연산 정밀도를 int8로 낮추고, 추론 사이에 GPU를 잠깐 쉬게 하고, "
|
||||
"조용할 때 모델을 내립니다."
|
||||
)
|
||||
self.game_mode_check.toggled.connect(self._on_performance_changed)
|
||||
form.addRow("", self.game_mode_check)
|
||||
|
||||
self.vram_slider, vram_row = _slider_row(
|
||||
int(perf.vram_limit_ratio * 100), 0, 90, "%", self._on_performance_changed
|
||||
)
|
||||
self.vram_slider.setToolTip(
|
||||
"이 프로그램이 쓸 수 있는 VRAM 상한입니다. 0%면 제한하지 않습니다.\n"
|
||||
"게임이 VRAM 부족으로 버벅이면 낮추세요."
|
||||
)
|
||||
form.addRow("VRAM 상한", vram_row)
|
||||
|
||||
self.yield_spin = _spin(perf.yield_ms, 0, 500, 10, " ms", self._on_performance_changed)
|
||||
self.yield_spin.setToolTip(
|
||||
"한 문장을 처리하고 나서 GPU를 놓아주는 시간입니다.\n"
|
||||
"길수록 게임이 부드럽고 자막은 조금 늦게 뜹니다."
|
||||
)
|
||||
form.addRow("GPU 양보 시간", self.yield_spin)
|
||||
|
||||
self.idle_spin = _spin(
|
||||
perf.idle_unload_s, 0, 3600, 30, " 초", self._on_performance_changed
|
||||
)
|
||||
self.idle_spin.setToolTip(
|
||||
"이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납합니다.\n"
|
||||
"다음 대사가 나오면 자동으로 다시 올라갑니다. 0이면 끕니다."
|
||||
)
|
||||
form.addRow("유휴 시 모델 해제", self.idle_spin)
|
||||
|
||||
self.perf_summary = QLabel()
|
||||
self.perf_summary.setObjectName("Hint")
|
||||
self.perf_summary.setWordWrap(True)
|
||||
card.add(self.perf_summary)
|
||||
self._update_perf_summary()
|
||||
return card
|
||||
|
||||
def _update_perf_summary(self) -> None:
|
||||
perf = self.config.performance
|
||||
gpu = detect_gpu()
|
||||
if not gpu.available or not gpu.total_vram_mb:
|
||||
self.perf_summary.setText("GPU 정보를 읽지 못해 상한을 계산할 수 없습니다.")
|
||||
return
|
||||
if perf.vram_limit_ratio <= 0:
|
||||
self.perf_summary.setText(
|
||||
f"{gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB — 상한 없음 "
|
||||
"(게임이 VRAM 부족으로 버벅일 수 있습니다)"
|
||||
)
|
||||
return
|
||||
budget = gpu.total_vram_mb * perf.vram_limit_ratio / 1024
|
||||
left = gpu.total_vram_mb / 1024 - budget
|
||||
self.perf_summary.setText(
|
||||
f"{gpu.name} · 자막이 최대 {budget:.1f}GB 사용, 게임 몫 약 {left:.1f}GB"
|
||||
)
|
||||
|
||||
# --- 단축키 ----------------------------------------------------------
|
||||
def _build_hotkey_card(self) -> Card:
|
||||
keys = self.config.hotkeys
|
||||
card = Card(
|
||||
"전역 단축키",
|
||||
"게임 창이 떠 있어도 동작합니다. Ctrl / Alt / Shift 중 하나는 반드시 넣으세요.",
|
||||
)
|
||||
form = card.add_form()
|
||||
|
||||
self.hotkey_enabled = QCheckBox("전역 단축키 사용")
|
||||
self.hotkey_enabled.setChecked(keys.enabled)
|
||||
self.hotkey_enabled.toggled.connect(self._on_hotkey_changed)
|
||||
form.addRow("", self.hotkey_enabled)
|
||||
|
||||
self.subtitle_hotkey = QLineEdit(keys.toggle_subtitle)
|
||||
self.subtitle_hotkey.setPlaceholderText("예: Ctrl+Alt+S")
|
||||
self.subtitle_hotkey.editingFinished.connect(self._on_hotkey_changed)
|
||||
form.addRow("자막 켜기/끄기", self.subtitle_hotkey)
|
||||
|
||||
self.engine_hotkey = QLineEdit(keys.toggle_engine)
|
||||
self.engine_hotkey.setPlaceholderText("예: Ctrl+Alt+D")
|
||||
self.engine_hotkey.editingFinished.connect(self._on_hotkey_changed)
|
||||
form.addRow("번역 시작/정지", self.engine_hotkey)
|
||||
|
||||
self.hotkey_status = QLabel()
|
||||
self.hotkey_status.setObjectName("Hint")
|
||||
self.hotkey_status.setWordWrap(True)
|
||||
card.add(self.hotkey_status)
|
||||
return card
|
||||
|
||||
def set_hotkey_status(self, message: str, ok: bool = True) -> None:
|
||||
p = palette("dark")
|
||||
self.hotkey_status.setText(message)
|
||||
self.hotkey_status.setStyleSheet(f"color: {p.text_dim if ok else p.warning};")
|
||||
|
||||
def _on_performance_changed(self, *_) -> None:
|
||||
perf = self.config.performance
|
||||
perf.game_mode = self.game_mode_check.isChecked()
|
||||
perf.vram_limit_ratio = self.vram_slider.value() / 100
|
||||
perf.yield_ms = self.yield_spin.value()
|
||||
perf.idle_unload_s = self.idle_spin.value()
|
||||
for widget in (self.vram_slider, self.yield_spin, self.idle_spin):
|
||||
widget.setEnabled(perf.game_mode)
|
||||
self._update_perf_summary()
|
||||
if hasattr(self, "partial_spin"): # 오디오 카드가 아직 안 만들어졌을 수 있다
|
||||
self._sync_partial_enabled()
|
||||
self.config_changed.emit()
|
||||
self.restart_needed.emit()
|
||||
|
||||
def _on_hotkey_changed(self, *_) -> None:
|
||||
keys = self.config.hotkeys
|
||||
keys.enabled = self.hotkey_enabled.isChecked()
|
||||
keys.toggle_subtitle = self.subtitle_hotkey.text().strip()
|
||||
keys.toggle_engine = self.engine_hotkey.text().strip()
|
||||
for widget in (self.subtitle_hotkey, self.engine_hotkey):
|
||||
widget.setEnabled(keys.enabled)
|
||||
self.config_changed.emit()
|
||||
self.hotkeys_changed.emit()
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_audio_card(self) -> Card:
|
||||
cfg = self.config.audio
|
||||
card = Card(
|
||||
"음성 인식",
|
||||
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
|
||||
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
|
||||
)
|
||||
form = card.add_form()
|
||||
|
||||
self.gain_slider, gain_row = _slider_row(
|
||||
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
|
||||
)
|
||||
form.addRow("입력 증폭", gain_row)
|
||||
|
||||
self.vad_combo = QComboBox()
|
||||
for label, value in (
|
||||
("낮음 — 작은 소리도 잡음", 0),
|
||||
("보통", 1),
|
||||
("높음 (기본)", 2),
|
||||
("매우 높음 — 또렷한 말만", 3),
|
||||
):
|
||||
self.vad_combo.addItem(label, value)
|
||||
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
|
||||
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
|
||||
form.addRow("잡음 억제", self.vad_combo)
|
||||
|
||||
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
|
||||
self.silence_spin.setToolTip(
|
||||
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
|
||||
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
|
||||
)
|
||||
form.addRow("문장 끊는 침묵", self.silence_spin)
|
||||
|
||||
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
|
||||
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
|
||||
form.addRow("문장 최대 길이", self.max_spin)
|
||||
|
||||
self.partial_spin = _spin(
|
||||
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
|
||||
)
|
||||
self.partial_spin.setToolTip(
|
||||
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다.\n"
|
||||
"같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 됩니다.\n"
|
||||
"저부하 모드가 켜져 있으면 무시됩니다."
|
||||
)
|
||||
self.partial_row_label = QLabel("중간 결과 갱신")
|
||||
form.addRow(self.partial_row_label, self.partial_spin)
|
||||
self._sync_partial_enabled()
|
||||
return card
|
||||
|
||||
def _sync_partial_enabled(self) -> None:
|
||||
"""저부하 모드에서는 중간 결과를 쓰지 않으므로 입력을 잠근다."""
|
||||
on = not self.config.performance.game_mode
|
||||
self.partial_spin.setEnabled(on)
|
||||
self.partial_row_label.setText(
|
||||
"중간 결과 갱신" if on else "중간 결과 갱신 (저부하 모드에서 꺼짐)"
|
||||
)
|
||||
|
||||
def _build_behavior_card(self) -> Card:
|
||||
card = Card("동작")
|
||||
form = card.add_form()
|
||||
|
||||
self.theme_combo = QComboBox()
|
||||
self.theme_combo.addItem("어두운 테마", "dark")
|
||||
self.theme_combo.addItem("밝은 테마", "light")
|
||||
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
|
||||
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
|
||||
form.addRow("테마", self.theme_combo)
|
||||
|
||||
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
|
||||
self.preload_check.setChecked(self.config.models.preload_on_start)
|
||||
self.preload_check.setToolTip(
|
||||
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
|
||||
)
|
||||
self.preload_check.toggled.connect(self._on_behavior_changed)
|
||||
form.addRow("", self.preload_check)
|
||||
|
||||
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
|
||||
self.log_check.setChecked(self.config.log_transcripts)
|
||||
self.log_check.toggled.connect(self._on_behavior_changed)
|
||||
form.addRow("", self.log_check)
|
||||
|
||||
open_folder = QPushButton("설정 폴더 열기")
|
||||
open_folder.clicked.connect(self._open_folder)
|
||||
form.addRow("", open_folder)
|
||||
return card
|
||||
|
||||
def _build_about_card(self) -> Card:
|
||||
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
|
||||
path = QLabel(f"설정 위치: {user_data_dir()}")
|
||||
path.setObjectName("Hint")
|
||||
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
path.setWordWrap(True)
|
||||
card.add(path)
|
||||
return card
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _on_audio_changed(self, *_) -> None:
|
||||
cfg = self.config.audio
|
||||
cfg.input_gain = self.gain_slider.value() / 100
|
||||
cfg.vad_aggressiveness = self.vad_combo.currentData()
|
||||
cfg.silence_ms = self.silence_spin.value()
|
||||
cfg.max_segment_ms = self.max_spin.value()
|
||||
cfg.partial_interval_ms = self.partial_spin.value()
|
||||
self.config_changed.emit()
|
||||
self.restart_needed.emit()
|
||||
|
||||
def _on_behavior_changed(self, *_) -> None:
|
||||
self.config.theme = self.theme_combo.currentData()
|
||||
self.config.models.preload_on_start = self.preload_check.isChecked()
|
||||
self.config.log_transcripts = self.log_check.isChecked()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _open_folder(self) -> None:
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
|
||||
|
||||
|
||||
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
|
||||
spin = QSpinBox()
|
||||
spin.setRange(low, high)
|
||||
spin.setSingleStep(step)
|
||||
spin.setSuffix(suffix)
|
||||
spin.setValue(value)
|
||||
spin.valueChanged.connect(slot)
|
||||
return spin
|
||||
|
||||
|
||||
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
|
||||
row = QWidget()
|
||||
row.setObjectName("Row")
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
slider = QSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(low, high)
|
||||
slider.setValue(value)
|
||||
label = QLabel(f"{value}{suffix}")
|
||||
label.setMinimumWidth(48)
|
||||
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
|
||||
slider.valueChanged.connect(slot)
|
||||
layout.addWidget(slider, 1)
|
||||
layout.addWidget(label)
|
||||
return slider, row
|
||||
360
src/livesub/ui/pages/subtitle_page.py
Normal file
360
src/livesub/ui/pages/subtitle_page.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""자막 — 글꼴·색·크기 등 표시 설정과 실시간 미리보기."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QFontComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...config import AppConfig
|
||||
from ..overlay import SubtitlePreview
|
||||
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
|
||||
from ..theme import SPACING
|
||||
from ..widgets import AnchorGrid, Card, PageHeader
|
||||
|
||||
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
|
||||
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
color_picked = Signal(str)
|
||||
|
||||
def __init__(self, color: str, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(52, 30)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.set_color(color)
|
||||
self.clicked.connect(self._pick)
|
||||
|
||||
@property
|
||||
def color(self) -> str:
|
||||
return self._color
|
||||
|
||||
def set_color(self, color: str) -> None:
|
||||
self._color = color
|
||||
self.setStyleSheet(
|
||||
f"background: {color}; border: 1px solid #3A4152; border-radius: 6px;"
|
||||
)
|
||||
|
||||
def _pick(self) -> None:
|
||||
chosen = QColorDialog.getColor(QColor(self._color), self, "색 선택")
|
||||
if chosen.isValid():
|
||||
self.set_color(chosen.name())
|
||||
self.color_picked.emit(chosen.name())
|
||||
|
||||
|
||||
class SubtitlePage(QWidget):
|
||||
style_changed = Signal()
|
||||
#: 자막 표시 on/off
|
||||
overlay_toggled = Signal(bool)
|
||||
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
|
||||
placement_changed = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(SPACING + 4)
|
||||
root.addWidget(
|
||||
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
|
||||
)
|
||||
|
||||
root.addWidget(self._build_display_card())
|
||||
root.addWidget(self._build_preview_card())
|
||||
root.addWidget(self._build_text_card())
|
||||
root.addWidget(self._build_layout_card())
|
||||
root.addStretch(1)
|
||||
|
||||
self._sync_preview()
|
||||
|
||||
# --- 구성 -----------------------------------------------------------
|
||||
def _build_display_card(self) -> Card:
|
||||
"""자막 on/off + 모니터 선택 + 디스코드식 위치 선택."""
|
||||
cfg = self.config.overlay
|
||||
card = Card(
|
||||
"표시 위치",
|
||||
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
|
||||
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
|
||||
)
|
||||
|
||||
# --- on/off + 단축키 안내 ---
|
||||
toggle_row = QWidget()
|
||||
toggle_layout = QHBoxLayout(toggle_row)
|
||||
toggle_layout.setContentsMargins(0, 0, 0, 0)
|
||||
toggle_layout.setSpacing(10)
|
||||
|
||||
self.overlay_check = QCheckBox("자막 켜기")
|
||||
self.overlay_check.setChecked(cfg.visible)
|
||||
self.overlay_check.setStyleSheet("font-weight: 700;")
|
||||
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
|
||||
toggle_layout.addWidget(self.overlay_check)
|
||||
|
||||
hotkey_hint = QLabel(f"게임 중에도 {self.config.hotkeys.toggle_subtitle} 로 켜고 끌 수 있습니다")
|
||||
hotkey_hint.setObjectName("Hint")
|
||||
toggle_layout.addWidget(hotkey_hint)
|
||||
toggle_layout.addStretch(1)
|
||||
|
||||
self.click_through_check = QCheckBox("클릭 통과")
|
||||
self.click_through_check.setChecked(cfg.click_through)
|
||||
self.click_through_check.setToolTip(
|
||||
"켜면 자막 위를 클릭해도 게임으로 전달됩니다. 게임 중에는 켜두세요."
|
||||
)
|
||||
self.click_through_check.toggled.connect(self._on_placement_changed)
|
||||
toggle_layout.addWidget(self.click_through_check)
|
||||
card.add(toggle_row)
|
||||
|
||||
# --- 모니터 선택 ---
|
||||
monitor_row = QWidget()
|
||||
monitor_layout = QHBoxLayout(monitor_row)
|
||||
monitor_layout.setContentsMargins(0, 0, 0, 0)
|
||||
monitor_layout.setSpacing(10)
|
||||
monitor_layout.addWidget(QLabel("모니터"))
|
||||
|
||||
self.screen_combo = QComboBox()
|
||||
self.screen_combo.currentIndexChanged.connect(self._on_screen_changed)
|
||||
monitor_layout.addWidget(self.screen_combo, 1)
|
||||
|
||||
refresh = QPushButton("새로고침")
|
||||
refresh.setToolTip("모니터를 연결하거나 뺐다면 눌러주세요.")
|
||||
refresh.clicked.connect(self.refresh_screens)
|
||||
monitor_layout.addWidget(refresh)
|
||||
card.add(monitor_row)
|
||||
|
||||
# --- 9분할 위치 ---
|
||||
self.anchor_grid = AnchorGrid()
|
||||
self.anchor_grid.anchor_selected.connect(self._on_anchor_selected)
|
||||
card.add(self.anchor_grid)
|
||||
|
||||
self.anchor_label = QLabel()
|
||||
self.anchor_label.setObjectName("Hint")
|
||||
self.anchor_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
card.add(self.anchor_label)
|
||||
|
||||
margin_row = QWidget()
|
||||
margin_layout = QHBoxLayout(margin_row)
|
||||
margin_layout.setContentsMargins(0, 0, 0, 0)
|
||||
margin_layout.setSpacing(10)
|
||||
margin_layout.addWidget(QLabel("가장자리 여백"))
|
||||
self.margin_spin = QSpinBox()
|
||||
self.margin_spin.setRange(0, 400)
|
||||
self.margin_spin.setSingleStep(10)
|
||||
self.margin_spin.setSuffix(" px")
|
||||
self.margin_spin.setValue(cfg.margin)
|
||||
self.margin_spin.valueChanged.connect(self._on_placement_changed)
|
||||
margin_layout.addWidget(self.margin_spin)
|
||||
margin_layout.addStretch(1)
|
||||
card.add(margin_row)
|
||||
|
||||
self.refresh_screens()
|
||||
return card
|
||||
|
||||
def _build_preview_card(self) -> Card:
|
||||
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
|
||||
self.preview = SubtitlePreview(self.config.subtitle)
|
||||
self.preview.set_sample(PREVIEW_TEXT, PREVIEW_SOURCE)
|
||||
card.add(self.preview)
|
||||
return card
|
||||
|
||||
def _build_text_card(self) -> Card:
|
||||
st = self.config.subtitle
|
||||
card = Card("글씨")
|
||||
form = card.add_form()
|
||||
|
||||
self.font_combo = QFontComboBox()
|
||||
available = set(QFontDatabase.families())
|
||||
self.font_combo.setCurrentFont(
|
||||
QFont(st.font_family if st.font_family in available else "Segoe UI")
|
||||
)
|
||||
self.font_combo.currentFontChanged.connect(self._on_font_changed)
|
||||
form.addRow("글꼴", self.font_combo)
|
||||
|
||||
self.size_spin = QSpinBox()
|
||||
self.size_spin.setRange(12, 120)
|
||||
self.size_spin.setSuffix(" px")
|
||||
self.size_spin.setValue(st.font_size)
|
||||
self.size_spin.valueChanged.connect(self._on_change)
|
||||
form.addRow("크기", self.size_spin)
|
||||
|
||||
self.bold_check = QCheckBox("굵게")
|
||||
self.bold_check.setChecked(st.bold)
|
||||
self.bold_check.toggled.connect(self._on_change)
|
||||
form.addRow("", self.bold_check)
|
||||
|
||||
self.text_color = ColorButton(st.text_color)
|
||||
self.text_color.color_picked.connect(self._on_change)
|
||||
form.addRow("번역문 색", self.text_color)
|
||||
|
||||
self.source_color = ColorButton(st.source_text_color)
|
||||
self.source_color.color_picked.connect(self._on_change)
|
||||
form.addRow("원문 색", self.source_color)
|
||||
|
||||
self.outline_color = ColorButton(st.outline_color)
|
||||
self.outline_color.color_picked.connect(self._on_change)
|
||||
form.addRow("외곽선 색", self.outline_color)
|
||||
|
||||
self.outline_spin = QSpinBox()
|
||||
self.outline_spin.setRange(0, 8)
|
||||
self.outline_spin.setSuffix(" px")
|
||||
self.outline_spin.setValue(st.outline_width)
|
||||
self.outline_spin.setToolTip("배경이 밝든 어둡든 글씨가 읽히게 해줍니다. 0이면 끕니다.")
|
||||
self.outline_spin.valueChanged.connect(self._on_change)
|
||||
form.addRow("외곽선 두께", self.outline_spin)
|
||||
return card
|
||||
|
||||
def _build_layout_card(self) -> Card:
|
||||
st = self.config.subtitle
|
||||
card = Card("배치와 배경")
|
||||
form = card.add_form()
|
||||
|
||||
self.bg_color = ColorButton(st.background_color)
|
||||
self.bg_color.color_picked.connect(self._on_change)
|
||||
form.addRow("배경 색", self.bg_color)
|
||||
|
||||
opacity_row = QWidget()
|
||||
opacity_row.setObjectName("Row")
|
||||
layout = QHBoxLayout(opacity_row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.opacity_slider.setRange(0, 100)
|
||||
self.opacity_slider.setValue(st.background_opacity)
|
||||
self.opacity_slider.valueChanged.connect(self._on_change)
|
||||
self.opacity_value = QLabel(f"{st.background_opacity}%")
|
||||
self.opacity_value.setMinimumWidth(42)
|
||||
layout.addWidget(self.opacity_slider, 1)
|
||||
layout.addWidget(self.opacity_value)
|
||||
form.addRow("배경 투명도", opacity_row)
|
||||
|
||||
self.align_combo = QComboBox()
|
||||
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
|
||||
self.align_combo.addItem(label, value)
|
||||
self.align_combo.setCurrentIndex(max(0, self.align_combo.findData(st.align)))
|
||||
self.align_combo.currentIndexChanged.connect(self._on_change)
|
||||
form.addRow("정렬", self.align_combo)
|
||||
|
||||
self.lines_spin = QSpinBox()
|
||||
self.lines_spin.setRange(1, 5)
|
||||
self.lines_spin.setSuffix(" 줄")
|
||||
self.lines_spin.setValue(st.max_lines)
|
||||
self.lines_spin.valueChanged.connect(self._on_change)
|
||||
form.addRow("최대 줄 수", self.lines_spin)
|
||||
|
||||
self.fade_spin = QSpinBox()
|
||||
self.fade_spin.setRange(0, 30_000)
|
||||
self.fade_spin.setSingleStep(500)
|
||||
self.fade_spin.setSuffix(" ms")
|
||||
self.fade_spin.setValue(st.fade_out_ms)
|
||||
self.fade_spin.setToolTip("이 시간이 지나면 자막을 지웁니다. 0이면 계속 남겨둡니다.")
|
||||
self.fade_spin.valueChanged.connect(self._on_change)
|
||||
form.addRow("자동 지우기", self.fade_spin)
|
||||
|
||||
self.show_source_check = QCheckBox("원문도 같이 표시")
|
||||
self.show_source_check.setChecked(st.show_source)
|
||||
self.show_source_check.toggled.connect(self._on_change)
|
||||
form.addRow("", self.show_source_check)
|
||||
|
||||
return card
|
||||
|
||||
# --- 표시 위치 -------------------------------------------------------
|
||||
def refresh_screens(self) -> None:
|
||||
"""연결된 모니터를 다시 읽어 콤보를 채운다."""
|
||||
cfg = self.config.overlay
|
||||
screens = list_screens()
|
||||
|
||||
self.screen_combo.blockSignals(True)
|
||||
self.screen_combo.clear()
|
||||
for screen in screens:
|
||||
self.screen_combo.addItem(screen.label, screen.index)
|
||||
if not screens:
|
||||
self.screen_combo.addItem("모니터를 찾지 못했습니다", -1)
|
||||
found = self.screen_combo.findData(cfg.screen_index)
|
||||
self.screen_combo.setCurrentIndex(found if found >= 0 else 0)
|
||||
self.screen_combo.blockSignals(False)
|
||||
|
||||
self.anchor_grid.set_anchor(cfg.anchor)
|
||||
self._update_anchor_label()
|
||||
|
||||
def sync_from_config(self) -> None:
|
||||
"""드래그 등으로 설정이 바뀐 뒤 화면 표시를 맞춘다."""
|
||||
cfg = self.config.overlay
|
||||
self.overlay_check.blockSignals(True)
|
||||
self.overlay_check.setChecked(cfg.visible)
|
||||
self.overlay_check.blockSignals(False)
|
||||
self.anchor_grid.set_anchor(cfg.anchor)
|
||||
self._update_anchor_label()
|
||||
|
||||
def _update_anchor_label(self) -> None:
|
||||
anchor = self.config.overlay.anchor
|
||||
if anchor == ANCHOR_CUSTOM:
|
||||
self.anchor_label.setText(
|
||||
"자유 배치 — 직접 옮긴 자리를 쓰고 있습니다. 칸을 누르면 다시 맞춰집니다."
|
||||
)
|
||||
else:
|
||||
self.anchor_label.setText(f"현재 위치: {ANCHOR_LABELS.get(anchor, anchor)}")
|
||||
|
||||
def _on_screen_changed(self) -> None:
|
||||
index = self.screen_combo.currentData()
|
||||
if index is None or index < 0:
|
||||
return
|
||||
self.config.overlay.screen_index = index
|
||||
# 모니터를 바꾸면 그 모니터 기준으로 다시 배치해야 한다. 자유 배치
|
||||
# 상태였다면 하단 중앙으로 되돌린다 — 이전 좌표는 다른 모니터 것이라
|
||||
# 그대로 쓰면 화면 밖으로 나간다.
|
||||
if self.config.overlay.anchor == ANCHOR_CUSTOM:
|
||||
self.config.overlay.anchor = "bottom-center"
|
||||
self.anchor_grid.set_anchor("bottom-center")
|
||||
self._update_anchor_label()
|
||||
self.placement_changed.emit()
|
||||
|
||||
def _on_anchor_selected(self, anchor: str) -> None:
|
||||
self.config.overlay.anchor = anchor
|
||||
self._update_anchor_label()
|
||||
self.placement_changed.emit()
|
||||
|
||||
def _on_placement_changed(self, *_) -> None:
|
||||
cfg = self.config.overlay
|
||||
cfg.margin = self.margin_spin.value()
|
||||
cfg.click_through = self.click_through_check.isChecked()
|
||||
self.placement_changed.emit()
|
||||
|
||||
# --- 동작 -----------------------------------------------------------
|
||||
def _on_font_changed(self, font: QFont) -> None:
|
||||
self.config.subtitle.font_family = font.family()
|
||||
self._on_change()
|
||||
|
||||
def _on_change(self, *_) -> None:
|
||||
st = self.config.subtitle
|
||||
st.font_size = self.size_spin.value()
|
||||
st.bold = self.bold_check.isChecked()
|
||||
st.text_color = self.text_color.color
|
||||
st.source_text_color = self.source_color.color
|
||||
st.outline_color = self.outline_color.color
|
||||
st.outline_width = self.outline_spin.value()
|
||||
st.background_color = self.bg_color.color
|
||||
st.background_opacity = self.opacity_slider.value()
|
||||
st.align = self.align_combo.currentData()
|
||||
st.max_lines = self.lines_spin.value()
|
||||
st.fade_out_ms = self.fade_spin.value()
|
||||
st.show_source = self.show_source_check.isChecked()
|
||||
|
||||
self.opacity_value.setText(f"{st.background_opacity}%")
|
||||
self._sync_preview()
|
||||
self.style_changed.emit()
|
||||
|
||||
def _sync_preview(self) -> None:
|
||||
# 미리보기는 설정 객체를 그대로 참조하므로 다시 그리기만 하면 된다.
|
||||
self.preview.setMinimumHeight(max(140, self.config.subtitle.font_size * 4))
|
||||
self.preview.update()
|
||||
141
src/livesub/ui/placement.py
Normal file
141
src/livesub/ui/placement.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""자막 창 배치 계산.
|
||||
|
||||
디스코드 오버레이와 같은 방식이다. 모니터를 고르고 9개 구역 중 하나를 누르면
|
||||
그 자리에 딱 붙는다. 세밀하게 옮기고 싶으면 그 다음에 드래그하면 된다.
|
||||
|
||||
Qt 에 의존하지 않는 순수 계산부(`resolve_rect`)와 Qt 화면 조회부를 나눠놨다.
|
||||
덕분에 모니터가 없는 환경에서도 배치 로직을 그대로 테스트할 수 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: 디스코드 오버레이와 같은 3x3 그리드. 값은 (가로 정렬, 세로 정렬).
|
||||
ANCHORS: dict[str, tuple[str, str]] = {
|
||||
"top-left": ("left", "top"),
|
||||
"top-center": ("center", "top"),
|
||||
"top-right": ("right", "top"),
|
||||
"middle-left": ("left", "middle"),
|
||||
"middle-center": ("center", "middle"),
|
||||
"middle-right": ("right", "middle"),
|
||||
"bottom-left": ("left", "bottom"),
|
||||
"bottom-center": ("center", "bottom"),
|
||||
"bottom-right": ("right", "bottom"),
|
||||
}
|
||||
|
||||
#: UI 표시용 이름 (그리드 순서와 동일)
|
||||
ANCHOR_LABELS: dict[str, str] = {
|
||||
"top-left": "좌상단",
|
||||
"top-center": "상단 중앙",
|
||||
"top-right": "우상단",
|
||||
"middle-left": "좌측 중앙",
|
||||
"middle-center": "정중앙",
|
||||
"middle-right": "우측 중앙",
|
||||
"bottom-left": "좌하단",
|
||||
"bottom-center": "하단 중앙",
|
||||
"bottom-right": "우하단",
|
||||
}
|
||||
|
||||
#: 자유 배치 — 사용자가 드래그로 직접 옮긴 상태
|
||||
ANCHOR_CUSTOM = "custom"
|
||||
|
||||
DEFAULT_ANCHOR = "bottom-center"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenInfo:
|
||||
"""모니터 하나. Qt 의 QScreen 에서 뽑아낸 값만 담는다."""
|
||||
|
||||
index: int
|
||||
name: str
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
is_primary: bool = False
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
tag = " (주 모니터)" if self.is_primary else ""
|
||||
return f"{self.index + 1}. {self.name} · {self.width}×{self.height}{tag}"
|
||||
|
||||
|
||||
def resolve_rect(
|
||||
screen: ScreenInfo,
|
||||
anchor: str,
|
||||
width: int,
|
||||
height: int,
|
||||
margin: int = 60,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""앵커와 크기로 최종 창 위치를 계산한다.
|
||||
|
||||
창이 모니터보다 크면 모니터 안에 들어가도록 줄인다. 그러지 않으면
|
||||
자막이 화면 밖으로 나가 영영 안 보이는 상태가 될 수 있다.
|
||||
"""
|
||||
width = max(200, min(width, screen.width - margin * 2))
|
||||
height = max(80, min(height, screen.height - margin * 2))
|
||||
|
||||
horizontal, vertical = ANCHORS.get(anchor, ANCHORS[DEFAULT_ANCHOR])
|
||||
|
||||
if horizontal == "left":
|
||||
x = screen.x + margin
|
||||
elif horizontal == "right":
|
||||
x = screen.x + screen.width - width - margin
|
||||
else:
|
||||
x = screen.x + (screen.width - width) // 2
|
||||
|
||||
if vertical == "top":
|
||||
y = screen.y + margin
|
||||
elif vertical == "bottom":
|
||||
y = screen.y + screen.height - height - margin
|
||||
else:
|
||||
y = screen.y + (screen.height - height) // 2
|
||||
|
||||
return x, y, width, height
|
||||
|
||||
|
||||
def screen_for_point(screens: list[ScreenInfo], x: int, y: int) -> ScreenInfo | None:
|
||||
"""해당 좌표를 품고 있는 모니터. 드래그로 옮긴 뒤 소속을 다시 찾을 때 쓴다."""
|
||||
for screen in screens:
|
||||
if screen.x <= x < screen.x + screen.width and screen.y <= y < screen.y + screen.height:
|
||||
return screen
|
||||
return None
|
||||
|
||||
|
||||
def pick_screen(screens: list[ScreenInfo], index: int) -> ScreenInfo | None:
|
||||
"""저장된 인덱스로 모니터를 찾는다. 모니터 구성이 바뀌었으면 주 모니터로."""
|
||||
if not screens:
|
||||
return None
|
||||
for screen in screens:
|
||||
if screen.index == index:
|
||||
return screen
|
||||
for screen in screens:
|
||||
if screen.is_primary:
|
||||
return screen
|
||||
return screens[0]
|
||||
|
||||
|
||||
def list_screens() -> list[ScreenInfo]:
|
||||
"""현재 연결된 모니터 목록 (Qt 필요)."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return []
|
||||
primary = app.primaryScreen()
|
||||
out: list[ScreenInfo] = []
|
||||
for index, screen in enumerate(app.screens()):
|
||||
geo = screen.geometry()
|
||||
out.append(
|
||||
ScreenInfo(
|
||||
index=index,
|
||||
name=screen.name() or f"모니터 {index + 1}",
|
||||
x=geo.x(),
|
||||
y=geo.y(),
|
||||
width=geo.width(),
|
||||
height=geo.height(),
|
||||
is_primary=screen is primary,
|
||||
)
|
||||
)
|
||||
return out
|
||||
210
src/livesub/ui/theme.py
Normal file
210
src/livesub/ui/theme.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""디자인 토큰과 QSS 스타일시트.
|
||||
|
||||
의도적으로 외부 테마 패키지를 쓰지 않는다. 자막 오버레이가 반투명·무테두리라
|
||||
플랫폼 기본 테마와 섞이면 오히려 지저분해지고, 색/간격을 한곳에서 관리하는 편이
|
||||
설정 화면의 '미리보기'와 실제 자막을 일치시키기에도 좋다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Palette:
|
||||
bg: str
|
||||
surface: str
|
||||
surface_alt: str
|
||||
border: str
|
||||
text: str
|
||||
text_dim: str
|
||||
accent: str
|
||||
accent_hover: str
|
||||
accent_text: str
|
||||
success: str
|
||||
warning: str
|
||||
danger: str
|
||||
|
||||
|
||||
DARK = Palette(
|
||||
bg="#0F1117",
|
||||
surface="#171A22",
|
||||
surface_alt="#1E222C",
|
||||
border="#2A2F3C",
|
||||
text="#E7EAF0",
|
||||
text_dim="#8B93A5",
|
||||
accent="#5B8CFF",
|
||||
accent_hover="#7AA2FF",
|
||||
accent_text="#0B0E14",
|
||||
success="#3DD68C",
|
||||
warning="#F5B84E",
|
||||
danger="#F2555A",
|
||||
)
|
||||
|
||||
LIGHT = Palette(
|
||||
bg="#F4F6FA",
|
||||
surface="#FFFFFF",
|
||||
surface_alt="#EDF0F6",
|
||||
border="#D9DEE9",
|
||||
text="#131722",
|
||||
text_dim="#5C6578",
|
||||
accent="#3B6EF0",
|
||||
accent_hover="#2A5AD6",
|
||||
accent_text="#FFFFFF",
|
||||
success="#17A46A",
|
||||
warning="#C9821B",
|
||||
danger="#D63A3F",
|
||||
)
|
||||
|
||||
RADIUS = 10
|
||||
SPACING = 12
|
||||
|
||||
|
||||
def palette(theme: str) -> Palette:
|
||||
return LIGHT if theme == "light" else DARK
|
||||
|
||||
|
||||
def stylesheet(theme: str = "dark") -> str:
|
||||
p = palette(theme)
|
||||
return f"""
|
||||
QWidget {{
|
||||
background: {p.bg};
|
||||
color: {p.text};
|
||||
font-family: "Pretendard", "Segoe UI Variable", "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
font-size: 14px;
|
||||
}}
|
||||
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
|
||||
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
|
||||
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
|
||||
QWidget#Row {{
|
||||
background: transparent;
|
||||
}}
|
||||
QLabel#Title {{ font-size: 22px; font-weight: 700; }}
|
||||
QLabel#Subtitle {{ font-size: 13px; color: {p.text_dim}; }}
|
||||
QLabel#SectionLabel {{ font-size: 12px; font-weight: 700; color: {p.text_dim};
|
||||
letter-spacing: 1px; }}
|
||||
QLabel#Hint {{ font-size: 12px; color: {p.text_dim}; }}
|
||||
|
||||
/* --- 사이드바 --- */
|
||||
QFrame#Sidebar {{
|
||||
background: {p.surface};
|
||||
border-right: 1px solid {p.border};
|
||||
}}
|
||||
QPushButton#NavButton {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: {RADIUS}px;
|
||||
padding: 11px 14px;
|
||||
text-align: left;
|
||||
color: {p.text_dim};
|
||||
font-size: 14px;
|
||||
}}
|
||||
QPushButton#NavButton:hover {{ background: {p.surface_alt}; color: {p.text}; }}
|
||||
QPushButton#NavButton:checked {{
|
||||
background: {p.accent};
|
||||
color: {p.accent_text};
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
/* --- 카드 --- */
|
||||
QFrame#Card {{
|
||||
background: {p.surface};
|
||||
border: 1px solid {p.border};
|
||||
border-radius: {RADIUS}px;
|
||||
}}
|
||||
QFrame#CardSelected {{
|
||||
background: {p.surface};
|
||||
border: 2px solid {p.accent};
|
||||
border-radius: {RADIUS}px;
|
||||
}}
|
||||
|
||||
/* --- 입력 위젯 --- */
|
||||
QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit, QListWidget, QTableWidget {{
|
||||
background: {p.surface_alt};
|
||||
border: 1px solid {p.border};
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
selection-background-color: {p.accent};
|
||||
selection-color: {p.accent_text};
|
||||
}}
|
||||
QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus {{
|
||||
border: 1px solid {p.accent};
|
||||
}}
|
||||
QComboBox::drop-down {{ border: none; width: 22px; }}
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {p.surface_alt};
|
||||
border: 1px solid {p.border};
|
||||
selection-background-color: {p.accent};
|
||||
outline: none;
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background: {p.surface};
|
||||
color: {p.text_dim};
|
||||
border: none;
|
||||
border-bottom: 1px solid {p.border};
|
||||
padding: 7px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
QTableWidget {{ gridline-color: {p.border}; }}
|
||||
|
||||
/* --- 버튼 --- */
|
||||
QPushButton {{
|
||||
background: {p.surface_alt};
|
||||
border: 1px solid {p.border};
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: {p.text};
|
||||
}}
|
||||
QPushButton:hover {{ border-color: {p.accent}; }}
|
||||
QPushButton:disabled {{ color: {p.text_dim}; border-color: {p.border}; }}
|
||||
QPushButton#Primary {{
|
||||
background: {p.accent};
|
||||
color: {p.accent_text};
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
padding: 11px 22px;
|
||||
}}
|
||||
QPushButton#Primary:hover {{ background: {p.accent_hover}; }}
|
||||
QPushButton#Primary:disabled {{ background: {p.border}; color: {p.text_dim}; }}
|
||||
QPushButton#Danger {{
|
||||
background: {p.danger};
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
padding: 11px 22px;
|
||||
}}
|
||||
|
||||
/* --- 기타 --- */
|
||||
QSlider::groove:horizontal {{
|
||||
height: 4px; background: {p.border}; border-radius: 2px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
width: 16px; height: 16px; margin: -6px 0;
|
||||
background: {p.accent}; border-radius: 8px;
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{ background: {p.accent}; border-radius: 2px; }}
|
||||
QProgressBar {{
|
||||
background: {p.surface_alt}; border: none; border-radius: 4px;
|
||||
height: 6px; text-align: center; color: transparent;
|
||||
}}
|
||||
QProgressBar::chunk {{ background: {p.accent}; border-radius: 4px; }}
|
||||
QCheckBox::indicator, QRadioButton::indicator {{
|
||||
width: 17px; height: 17px;
|
||||
border: 1px solid {p.border}; border-radius: 4px;
|
||||
background: {p.surface_alt};
|
||||
}}
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
|
||||
background: {p.accent}; border-color: {p.accent};
|
||||
}}
|
||||
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {p.border}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p.text_dim}; }}
|
||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||
QScrollArea {{ border: none; }}
|
||||
QToolTip {{
|
||||
background: {p.surface_alt}; color: {p.text};
|
||||
border: 1px solid {p.border}; padding: 6px; border-radius: 6px;
|
||||
}}
|
||||
"""
|
||||
4
src/livesub/ui/widgets/__init__.py
Normal file
4
src/livesub/ui/widgets/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .anchor_grid import AnchorGrid
|
||||
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
|
||||
|
||||
__all__ = ["AnchorGrid", "Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]
|
||||
69
src/livesub/ui/widgets/anchor_grid.py
Normal file
69
src/livesub/ui/widgets/anchor_grid.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""디스코드 오버레이식 9분할 위치 선택기.
|
||||
|
||||
모니터 모양의 사각형 안에 3x3 버튼을 놓고, 누르면 자막이 그 자리로 붙는다.
|
||||
좌표를 숫자로 입력하게 하는 것보다 훨씬 직관적이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QButtonGroup, QFrame, QGridLayout, QPushButton, QWidget
|
||||
|
||||
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, ANCHORS
|
||||
from ..theme import palette
|
||||
|
||||
GRID_ORDER = [
|
||||
["top-left", "top-center", "top-right"],
|
||||
["middle-left", "middle-center", "middle-right"],
|
||||
["bottom-left", "bottom-center", "bottom-right"],
|
||||
]
|
||||
|
||||
|
||||
class AnchorGrid(QFrame):
|
||||
"""3x3 위치 버튼. 선택된 칸이 강조된다."""
|
||||
|
||||
anchor_selected = Signal(str)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Card")
|
||||
self.setFixedHeight(190)
|
||||
|
||||
p = palette("dark")
|
||||
layout = QGridLayout(self)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self._buttons: dict[str, QPushButton] = {}
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
|
||||
for row, keys in enumerate(GRID_ORDER):
|
||||
for col, key in enumerate(keys):
|
||||
button = QPushButton()
|
||||
button.setCheckable(True)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setToolTip(ANCHOR_LABELS[key])
|
||||
button.setMinimumHeight(42)
|
||||
button.setStyleSheet(
|
||||
f"QPushButton {{ background: {p.surface_alt};"
|
||||
f"border: 1px solid {p.border}; border-radius: 6px; }}"
|
||||
f"QPushButton:hover {{ border-color: {p.accent}; }}"
|
||||
f"QPushButton:checked {{ background: {p.accent};"
|
||||
f"border-color: {p.accent}; }}"
|
||||
)
|
||||
button.clicked.connect(lambda _, k=key: self.anchor_selected.emit(k))
|
||||
self._group.addButton(button)
|
||||
self._buttons[key] = button
|
||||
layout.addWidget(button, row, col)
|
||||
|
||||
def set_anchor(self, anchor: str) -> None:
|
||||
"""현재 선택 표시. 자유 배치(custom)면 아무 칸도 켜지 않는다."""
|
||||
self._group.setExclusive(False)
|
||||
for key, button in self._buttons.items():
|
||||
button.setChecked(anchor != ANCHOR_CUSTOM and key == anchor)
|
||||
self._group.setExclusive(True)
|
||||
|
||||
@staticmethod
|
||||
def is_valid(anchor: str) -> bool:
|
||||
return anchor in ANCHORS
|
||||
141
src/livesub/ui/widgets/common.py
Normal file
141
src/livesub/ui/widgets/common.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""여러 화면에서 재사용하는 작은 위젯들."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..theme import SPACING, palette
|
||||
|
||||
|
||||
class Card(QFrame):
|
||||
"""제목 + 본문 레이아웃을 가진 카드 컨테이너."""
|
||||
|
||||
def __init__(self, title: str = "", subtitle: str = "", parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Card")
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(SPACING + 4, SPACING + 4, SPACING + 4, SPACING + 4)
|
||||
outer.setSpacing(SPACING)
|
||||
|
||||
if title:
|
||||
label = QLabel(title)
|
||||
label.setStyleSheet("font-size: 15px; font-weight: 700;")
|
||||
outer.addWidget(label)
|
||||
if subtitle:
|
||||
hint = QLabel(subtitle)
|
||||
hint.setObjectName("Hint")
|
||||
hint.setWordWrap(True)
|
||||
outer.addWidget(hint)
|
||||
|
||||
self.body = QVBoxLayout()
|
||||
self.body.setSpacing(SPACING)
|
||||
outer.addLayout(self.body)
|
||||
|
||||
def add(self, widget: QWidget) -> QWidget:
|
||||
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
|
||||
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
|
||||
if type(widget) is QWidget:
|
||||
widget.setObjectName("Row")
|
||||
self.body.addWidget(widget)
|
||||
return widget
|
||||
|
||||
def add_form(self) -> QFormLayout:
|
||||
form = QFormLayout()
|
||||
form.setSpacing(10)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||
self.body.addLayout(form)
|
||||
return form
|
||||
|
||||
|
||||
class PageHeader(QWidget):
|
||||
def __init__(self, title: str, subtitle: str = "", parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
head = QLabel(title)
|
||||
head.setObjectName("Title")
|
||||
layout.addWidget(head)
|
||||
if subtitle:
|
||||
sub = QLabel(subtitle)
|
||||
sub.setObjectName("Subtitle")
|
||||
sub.setWordWrap(True)
|
||||
layout.addWidget(sub)
|
||||
|
||||
|
||||
class StatusPill(QLabel):
|
||||
"""상태를 색 점 + 문구로 보여주는 배지."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._color = "#8B93A5"
|
||||
self.setContentsMargins(10, 6, 12, 6)
|
||||
self.set_status("대기 중", "idle")
|
||||
|
||||
def set_status(self, text: str, kind: str = "idle") -> None:
|
||||
p = palette("dark")
|
||||
self._color = {
|
||||
"idle": p.text_dim,
|
||||
"busy": p.warning,
|
||||
"ok": p.success,
|
||||
"error": p.danger,
|
||||
}.get(kind, p.text_dim)
|
||||
self.setText(f"● {text}")
|
||||
self.setStyleSheet(
|
||||
f"color: {self._color}; background: {p.surface_alt};"
|
||||
f"border-radius: 13px; padding: 6px 14px; font-weight: 600;"
|
||||
)
|
||||
|
||||
|
||||
class LevelMeter(QWidget):
|
||||
"""입력 오디오 레벨 바. 소리가 잡히는지 한눈에 확인하는 용도."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._level = 0.0
|
||||
self._peak = 0.0
|
||||
self.setFixedHeight(8)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
def set_level(self, value: float) -> None:
|
||||
# RMS를 그대로 그리면 거의 안 움직여서 제곱근으로 펴준다.
|
||||
self._level = min(1.0, max(0.0, value) ** 0.5 * 2.2)
|
||||
self._peak = max(self._level, self._peak * 0.92)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802
|
||||
p = palette("dark")
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(p.surface_alt))
|
||||
painter.drawRoundedRect(self.rect(), 4, 4)
|
||||
if self._level > 0.01:
|
||||
color = QColor(p.danger if self._level > 0.92 else p.success)
|
||||
rect = self.rect().adjusted(0, 0, -int(self.width() * (1 - self._level)), 0)
|
||||
painter.setBrush(color)
|
||||
painter.drawRoundedRect(rect, 4, 4)
|
||||
|
||||
|
||||
def labelled_row(label: str, widget: QWidget) -> QWidget:
|
||||
row = QWidget()
|
||||
row.setObjectName("Row")
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
text = QLabel(label)
|
||||
text.setMinimumWidth(110)
|
||||
layout.addWidget(text)
|
||||
layout.addWidget(widget, 1)
|
||||
return row
|
||||
Reference in New Issue
Block a user