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:
EJClaw
2026-09-21 17:49:26 +09:00
parent 5596f905d1
commit 8b36b244ff
60 changed files with 1939 additions and 300 deletions

6
src/livesub/__init__.py Normal file
View 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
View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
raise SystemExit(main())

53
src/livesub/app.py Normal file
View 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())

View 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
View 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

View 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

View 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 ""

View 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

View 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
View 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
View 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

View 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
View 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)

View 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

View 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": "我的错"}}
]
}

View 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": "满装备"}}
]
}

View 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": "农场"}}
]
}

View 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": "推进"}}
]
}

View 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": "欢迎回来"}}
]
}

View 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": "赛点"}}
]
}

View 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
View 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

View 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
View 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

View 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"]

View 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

View 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()

View 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

View 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

View 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
View 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
View 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;
}}
"""

View 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"]

View 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

View 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