Compare commits
5 Commits
v0.3.0-tes
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dde1b2aaa | ||
|
|
0201c7b913 | ||
|
|
bec5e285bd | ||
|
|
7f678fba7b | ||
|
|
628280b227 |
@@ -25,12 +25,14 @@
|
|||||||
## 주요 기능
|
## 주요 기능
|
||||||
|
|
||||||
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
|
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
|
||||||
|
- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다
|
||||||
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
|
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
|
||||||
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
|
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
|
||||||
|
- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다
|
||||||
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
|
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
|
||||||
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
|
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
|
||||||
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
|
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다
|
||||||
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
|
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다)
|
||||||
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
|
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
|
||||||
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
|
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
|
||||||
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
|
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
|
||||||
|
|||||||
@@ -14,9 +14,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -393,6 +397,135 @@ def run_gpu_checks() -> None:
|
|||||||
# --- 빌드된 exe 점검 --------------------------------------------------------
|
# --- 빌드된 exe 점검 --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: PyInstaller 가 진입 스크립트에서 예외가 났을 때 띄우는 창.
|
||||||
|
_CRASH_MARKERS = ("unhandled exception", "failed to execute script")
|
||||||
|
|
||||||
|
|
||||||
|
def _process_tree(pid: int) -> set[int]:
|
||||||
|
"""onefile exe 는 압축을 푼 뒤 자식 프로세스로 진짜 앱을 띄운다.
|
||||||
|
|
||||||
|
그래서 창의 주인은 우리가 Popen 한 pid 가 아니라 그 자식이다. 부모만 보면
|
||||||
|
창을 영영 못 찾는다.
|
||||||
|
"""
|
||||||
|
pids = {pid}
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
parent = psutil.Process(pid)
|
||||||
|
pids.update(child.pid for child in parent.children(recursive=True))
|
||||||
|
except Exception: # noqa: BLE001 - psutil 이 없어도 부모만으로 계속한다
|
||||||
|
pass
|
||||||
|
return pids
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_windows(pids: set[int]) -> list[str]:
|
||||||
|
"""그 프로세스들이 띄운 보이는 최상위 창의 제목."""
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
titles: list[str] = []
|
||||||
|
|
||||||
|
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||||
|
def _collect(hwnd, _lparam):
|
||||||
|
owner = wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
||||||
|
if owner.value in pids and user32.IsWindowVisible(hwnd):
|
||||||
|
length = user32.GetWindowTextLengthW(hwnd)
|
||||||
|
buf = ctypes.create_unicode_buffer(length + 1)
|
||||||
|
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||||
|
if buf.value:
|
||||||
|
titles.append(buf.value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.EnumWindows(_collect, 0)
|
||||||
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_tree(proc: subprocess.Popen) -> None:
|
||||||
|
"""부모만 죽이면 안 된다.
|
||||||
|
|
||||||
|
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고, 창과 로그 파일을 쥐고 있는
|
||||||
|
것은 자식이다. 부모만 terminate 하면 자식이 살아남아 로그 파일을 계속
|
||||||
|
잡고 있고, 임시 폴더를 지울 수 없어 점검이 엉뚱한 곳에서 실패한다.
|
||||||
|
"""
|
||||||
|
children = []
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
children = psutil.Process(proc.pid).children(recursive=True)
|
||||||
|
except Exception: # noqa: BLE001 - psutil 이 없으면 부모만이라도 정리한다
|
||||||
|
pass
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
child.terminate()
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
for child in children:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
child.wait(timeout=10)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
if child.is_running():
|
||||||
|
child.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_and_wait_for_window(
|
||||||
|
exe: Path, appdata: Path, require: str, timeout_s: int = 90
|
||||||
|
) -> str:
|
||||||
|
"""exe 를 띄우고 **기다리던 창이 뜰 때까지** 기다린다.
|
||||||
|
|
||||||
|
예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입
|
||||||
|
스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 —
|
||||||
|
프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가
|
||||||
|
릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다.
|
||||||
|
|
||||||
|
"아무 창이나 뜨면 통과" 도 부족하다. 자막 오버레이는 제목이 앱 이름
|
||||||
|
그대로라, 본 화면이 안 떠도 그게 잡혀 통과해 버린다. 그래서 어떤 창을
|
||||||
|
기다리는지(`require`)를 호출하는 쪽이 지정한다.
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["APPDATA"] = str(appdata)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[str(exe)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||||
|
raise RuntimeError(f"창이 뜨기 전에 종료 (코드 {proc.returncode}): {err}")
|
||||||
|
|
||||||
|
titles = _visible_windows(_process_tree(proc.pid))
|
||||||
|
for title in titles:
|
||||||
|
if any(marker in title.lower() for marker in _CRASH_MARKERS):
|
||||||
|
raise RuntimeError(f"오류창이 떴습니다: {title!r}")
|
||||||
|
if any(require in title for title in titles):
|
||||||
|
return " / ".join(titles)
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError(f"{timeout_s}초 안에 {require!r} 창이 뜨지 않았습니다")
|
||||||
|
finally:
|
||||||
|
_terminate_tree(proc)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _scratch_appdata():
|
||||||
|
"""점검용 %APPDATA%.
|
||||||
|
|
||||||
|
치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가
|
||||||
|
파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로
|
||||||
|
튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다.
|
||||||
|
"""
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-"))
|
||||||
|
try:
|
||||||
|
yield tmp
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def run_exe_check(exe: Path) -> None:
|
def run_exe_check(exe: Path) -> None:
|
||||||
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
||||||
|
|
||||||
@@ -405,21 +538,37 @@ def run_exe_check(exe: Path) -> None:
|
|||||||
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||||
return f"{mb:.1f} MB"
|
return f"{mb:.1f} MB"
|
||||||
|
|
||||||
@check("exe 실행 후 8초 생존")
|
@check("exe 첫 실행 — 저장 위치 화면")
|
||||||
def _launch():
|
def _first_run():
|
||||||
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
|
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
|
||||||
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
with _scratch_appdata() as tmp:
|
||||||
time.sleep(8)
|
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
|
||||||
alive = proc.poll() is None
|
return f"창 {titles!r}"
|
||||||
if not alive:
|
|
||||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
@check("exe 두 번째 실행 — 본 화면")
|
||||||
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
|
def _second_run():
|
||||||
proc.terminate()
|
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
|
||||||
try:
|
with _scratch_appdata() as tmp:
|
||||||
proc.wait(timeout=10)
|
data = tmp / "data"
|
||||||
except subprocess.TimeoutExpired:
|
data.mkdir()
|
||||||
proc.kill()
|
pointer = tmp / "LiveSub"
|
||||||
return "정상 기동"
|
pointer.mkdir()
|
||||||
|
(pointer / "location.json").write_text(
|
||||||
|
json.dumps({"data_dir": str(data)}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
# 모델 미리 올리기를 꺼둔다. 켜두면 창을 확인하러 온 점검이
|
||||||
|
# 수 GB 짜리 모델 내려받기를 시작했다가 중간에 죽인다.
|
||||||
|
(data / "config.json").write_text(
|
||||||
|
json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
# 자막 오버레이는 제목이 앱 이름뿐이라 그것만으로는 본 화면이 떴다는
|
||||||
|
# 증거가 못 된다. 본 화면 제목에만 있는 표어를 기다린다.
|
||||||
|
from livesub.constants import APP_SLOGAN
|
||||||
|
|
||||||
|
titles = _launch_and_wait_for_window(exe, appdata=tmp, require=APP_SLOGAN)
|
||||||
|
if any("저장 위치" in t for t in titles.split(" / ")):
|
||||||
|
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {titles!r}")
|
||||||
|
return f"창 {titles!r}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
from .app import main
|
"""`python -m livesub` 와 포터블 exe 의 진입점.
|
||||||
|
|
||||||
|
**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이
|
||||||
|
최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import`
|
||||||
|
는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다.
|
||||||
|
exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다.
|
||||||
|
|
||||||
|
`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from livesub.app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -7,17 +7,25 @@ import logging
|
|||||||
from ..config import AudioConfig
|
from ..config import AudioConfig
|
||||||
from .base import AudioSource, CaptureBackend, CaptureError
|
from .base import AudioSource, CaptureBackend, CaptureError
|
||||||
from .file_source import FileCapture
|
from .file_source import FileCapture
|
||||||
|
from .input_device import InputDeviceCapture
|
||||||
from .process_loopback import ProcessLoopbackCapture
|
from .process_loopback import ProcessLoopbackCapture
|
||||||
from .segmenter import Segment, Segmenter, SegmenterConfig
|
from .segmenter import Segment, Segmenter, SegmenterConfig
|
||||||
from .wasapi_loopback import WasapiLoopbackCapture
|
from .wasapi_loopback import WasapiLoopbackCapture
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 홈 화면 목록이 무엇을 보여줄지.
|
||||||
|
SOURCE_MODE_OUTPUT = "output" # 게임 등 프로그램 소리 + 출력 장치
|
||||||
|
SOURCE_MODE_INPUT = "input" # 마이크 등 입력 장치
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"SOURCE_MODE_INPUT",
|
||||||
|
"SOURCE_MODE_OUTPUT",
|
||||||
"AudioSource",
|
"AudioSource",
|
||||||
"CaptureBackend",
|
"CaptureBackend",
|
||||||
"CaptureError",
|
"CaptureError",
|
||||||
"FileCapture",
|
"FileCapture",
|
||||||
|
"InputDeviceCapture",
|
||||||
"ProcessLoopbackCapture",
|
"ProcessLoopbackCapture",
|
||||||
"Segment",
|
"Segment",
|
||||||
"Segmenter",
|
"Segmenter",
|
||||||
@@ -34,12 +42,20 @@ def capture_capabilities() -> dict[str, bool]:
|
|||||||
return {
|
return {
|
||||||
"process": ProcessLoopbackCapture.available(),
|
"process": ProcessLoopbackCapture.available(),
|
||||||
"device": WasapiLoopbackCapture.available(),
|
"device": WasapiLoopbackCapture.available(),
|
||||||
|
"input": InputDeviceCapture.available(),
|
||||||
"file": True,
|
"file": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_sources() -> list[AudioSource]:
|
def list_sources(mode: str = SOURCE_MODE_OUTPUT) -> list[AudioSource]:
|
||||||
"""프로그램 + 출력 장치를 한 목록으로."""
|
"""고른 쪽의 캡처 대상 목록.
|
||||||
|
|
||||||
|
출력(게임 소리)과 입력(마이크)을 한 목록에 섞으면 수십 줄이 되고, 정작
|
||||||
|
찾으려던 게임이 마이크·라인입력 사이에 파묻힌다. 그래서 한 번에 한 쪽만
|
||||||
|
보여주고 홈 화면에서 전환한다.
|
||||||
|
"""
|
||||||
|
if mode == SOURCE_MODE_INPUT:
|
||||||
|
return InputDeviceCapture.list_sources()
|
||||||
sources: list[AudioSource] = []
|
sources: list[AudioSource] = []
|
||||||
if ProcessLoopbackCapture.available():
|
if ProcessLoopbackCapture.available():
|
||||||
sources.extend(ProcessLoopbackCapture.list_sources())
|
sources.extend(ProcessLoopbackCapture.list_sources())
|
||||||
@@ -53,6 +69,13 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
|
|||||||
if backend == "file":
|
if backend == "file":
|
||||||
return FileCapture(config.target_process_name)
|
return FileCapture(config.target_process_name)
|
||||||
|
|
||||||
|
if backend == "input":
|
||||||
|
if not InputDeviceCapture.available():
|
||||||
|
raise CaptureError(
|
||||||
|
"입력 장치를 열 수 없습니다. Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
|
||||||
|
)
|
||||||
|
return InputDeviceCapture(config.device_index)
|
||||||
|
|
||||||
want_process = backend in ("process", "auto") and config.target_pid > 0
|
want_process = backend in ("process", "auto") and config.target_pid > 0
|
||||||
if want_process:
|
if want_process:
|
||||||
if ProcessLoopbackCapture.available():
|
if ProcessLoopbackCapture.available():
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from ..constants import SAMPLE_RATE
|
|||||||
class AudioSource:
|
class AudioSource:
|
||||||
"""UI에 노출되는 캡처 대상 한 줄."""
|
"""UI에 노출되는 캡처 대상 한 줄."""
|
||||||
|
|
||||||
kind: str # "process" | "device"
|
#: "process"=특정 프로그램 소리 · "device"=출력 장치 루프백 · "input"=마이크 등 입력 장치
|
||||||
|
kind: str
|
||||||
identifier: str # pid 문자열 또는 장치 인덱스
|
identifier: str # pid 문자열 또는 장치 인덱스
|
||||||
label: str
|
label: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
@@ -32,7 +33,7 @@ class AudioSource:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_index(self) -> int:
|
def device_index(self) -> int:
|
||||||
return int(self.identifier) if self.kind == "device" else -1
|
return int(self.identifier) if self.kind in ("device", "input") else -1
|
||||||
|
|
||||||
|
|
||||||
class CaptureError(RuntimeError):
|
class CaptureError(RuntimeError):
|
||||||
|
|||||||
106
src/livesub/audio/input_device.py
Normal file
106
src/livesub/audio/input_device.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""마이크 등 입력 장치 캡처 (Windows).
|
||||||
|
|
||||||
|
루프백(=스피커로 나가는 소리)이 아니라 **들어오는 소리**를 받는다. 통화나
|
||||||
|
합주처럼 상대 목소리가 내 스피커로 나오지 않는 상황, 또는 내가 말하는 것을
|
||||||
|
그대로 번역해 보고 싶을 때 쓴다.
|
||||||
|
|
||||||
|
WasapiLoopbackCapture 와 스트림을 여는 모양이 비슷하지만 합치지 않았다.
|
||||||
|
장치 목록을 만드는 방법(루프백 전용 열거 vs 일반 입력 장치)과 기본 장치를
|
||||||
|
고르는 방법이 서로 달라서, 한 클래스에 넣으면 분기가 코드의 절반이 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 InputDeviceCapture(CaptureBackend):
|
||||||
|
name = "input"
|
||||||
|
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 = int(pa.get_default_input_device_info()["index"])
|
||||||
|
for i in range(pa.get_device_count()):
|
||||||
|
info = pa.get_device_info_by_index(i)
|
||||||
|
if int(info.get("maxInputChannels", 0)) <= 0:
|
||||||
|
continue
|
||||||
|
# 루프백 장치는 '출력 장치' 목록에서 이미 보여주고 있다.
|
||||||
|
# 여기서 또 내보내면 같은 것이 두 목록에 걸쳐 나온다.
|
||||||
|
if info.get("isLoopbackDevice"):
|
||||||
|
continue
|
||||||
|
sources.append(
|
||||||
|
AudioSource(
|
||||||
|
kind="input",
|
||||||
|
identifier=str(i),
|
||||||
|
label=str(info["name"]),
|
||||||
|
detail="기본 입력 장치" if i == 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_input_device_info()
|
||||||
|
|
||||||
|
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 = max(1, int(info["maxInputChannels"]))
|
||||||
|
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()
|
||||||
@@ -35,6 +35,9 @@ class SubtitleStyle:
|
|||||||
outline_width: int = 3
|
outline_width: int = 3
|
||||||
background_color: str = "#000000"
|
background_color: str = "#000000"
|
||||||
background_opacity: int = 55 # 0-100
|
background_opacity: int = 55 # 0-100
|
||||||
|
#: 글자와 배경 상자 가장자리 사이 여백. 배경 상자는 글자를 감싸는 크기로
|
||||||
|
#: 그려지므로 이 값이 곧 배경 크기를 정한다.
|
||||||
|
padding: int = 18
|
||||||
line_spacing: int = 6
|
line_spacing: int = 6
|
||||||
max_lines: int = 2
|
max_lines: int = 2
|
||||||
show_source: bool = False # 원문 동시 표시
|
show_source: bool = False # 원문 동시 표시
|
||||||
@@ -53,7 +56,9 @@ class OverlayConfig:
|
|||||||
always_on_top: bool = True
|
always_on_top: bool = True
|
||||||
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
|
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
|
||||||
click_through: bool = True
|
click_through: bool = True
|
||||||
locked: bool = False
|
#: 기본은 잠금이다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는다.
|
||||||
|
#: 옮기거나 크기를 바꿀 때는 자막 화면의 '자막 위치 조정'을 켠다.
|
||||||
|
locked: bool = True
|
||||||
#: 자막 표시 on/off (단축키로 토글)
|
#: 자막 표시 on/off (단축키로 토글)
|
||||||
visible: bool = True
|
visible: bool = True
|
||||||
#: 자막을 띄울 모니터 인덱스 (0부터)
|
#: 자막을 띄울 모니터 인덱스 (0부터)
|
||||||
@@ -66,7 +71,10 @@ class OverlayConfig:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioConfig:
|
class AudioConfig:
|
||||||
backend: str = "auto" # auto | process | device | file
|
backend: str = "auto" # auto | process | device | input | file
|
||||||
|
#: 홈 화면 목록이 무엇을 보여줄지. "output"=프로그램과 출력 장치(게임 소리),
|
||||||
|
#: "input"=마이크 같은 입력 장치. 고른 쪽을 기억한다.
|
||||||
|
source_mode: str = "output"
|
||||||
target_pid: int = 0
|
target_pid: int = 0
|
||||||
target_process_name: str = ""
|
target_process_name: str = ""
|
||||||
device_index: int = -1 # -1 = 기본 출력 장치 루프백
|
device_index: int = -1 # -1 = 기본 출력 장치 루프백
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from .asr import SpeechRecognizer, Transcript
|
from .asr import SpeechRecognizer, Transcript
|
||||||
from .glossary import Glossary, GlossaryEntry
|
from .glossary import Glossary, GlossaryEntry
|
||||||
from .manager import GpuInfo, ModelManager, detect_gpu
|
from .manager import GpuInfo, ModelManager, detect_gpu, recommended_tier
|
||||||
from .speech_level import (
|
from .speech_level import (
|
||||||
Politeness,
|
Politeness,
|
||||||
SpeechLevel,
|
SpeechLevel,
|
||||||
@@ -43,5 +43,6 @@ __all__ = [
|
|||||||
"detect_gpu",
|
"detect_gpu",
|
||||||
"get_tier",
|
"get_tier",
|
||||||
"ordered_tiers",
|
"ordered_tiers",
|
||||||
|
"recommended_tier",
|
||||||
"to_casual",
|
"to_casual",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
|
|||||||
|
|
||||||
from ..config import ModelConfig
|
from ..config import ModelConfig
|
||||||
from .asr import SpeechRecognizer
|
from .asr import SpeechRecognizer
|
||||||
from .tiers import TIERS, MTBackend, Tier, get_tier
|
from .tiers import DEFAULT_TIER, TIERS, MTBackend, Tier, get_tier
|
||||||
from .translator import Translator, create_translator
|
from .translator import Translator, create_translator
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -57,7 +57,10 @@ def detect_gpu() -> GpuInfo:
|
|||||||
return GpuInfo(True, name, int(total), int(free),
|
return GpuInfo(True, name, int(total), int(free),
|
||||||
reason="PyTorch 미설치 — 정보만 조회했습니다.")
|
reason="PyTorch 미설치 — 정보만 조회했습니다.")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return GpuInfo(False, reason=f"GPU 조회 실패: {exc}")
|
# 예외 문자열에는 실행한 명령줄이 통째로 들어 있다. 그대로 화면에
|
||||||
|
# 띄우면 안내가 아니라 로그 덤프가 된다. 자세한 건 로그로 보낸다.
|
||||||
|
log.warning("nvidia-smi 조회 실패: %s", exc)
|
||||||
|
return GpuInfo(False, reason="nvidia-smi 로 GPU 정보를 읽지 못했습니다.")
|
||||||
|
|
||||||
|
|
||||||
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
|
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
|
||||||
@@ -136,6 +139,32 @@ def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_tier(gpu: GpuInfo | None = None) -> str:
|
||||||
|
"""이 환경에서 실제로 권할 만한 티어.
|
||||||
|
|
||||||
|
티어 표에 박아둔 `recommended` 는 "GPU 가 넉넉할 때" 를 전제로 한 값이라,
|
||||||
|
그대로 쓰면 GPU 가 없는 사람에게 10GB 짜리를 추천하게 된다. 그래서 환경을
|
||||||
|
보고 정한다.
|
||||||
|
|
||||||
|
GPU 가 없으면 CTranslate2 계열(1~3티어)만 후보에 둔다. LLM 계열은 CPU 에서
|
||||||
|
한 문장에 수십 초가 걸려 실시간 자막으로 쓸 수 없다. 그중에서도 가장
|
||||||
|
가벼운 쪽을 고른다 — CPU 에서는 연산량이 그대로 지연으로 돌아온다.
|
||||||
|
"""
|
||||||
|
info = detect_gpu() if gpu is None else gpu
|
||||||
|
usable = [t for t in TIERS.values() if tier_availability(t, info)[0]]
|
||||||
|
if not usable:
|
||||||
|
return DEFAULT_TIER
|
||||||
|
|
||||||
|
if not info.available:
|
||||||
|
light = [t for t in usable if t.mt.backend is MTBackend.CTRANSLATE2]
|
||||||
|
return min(light or usable, key=lambda t: t.order).key
|
||||||
|
|
||||||
|
preferred = [t for t in usable if t.recommended]
|
||||||
|
if preferred:
|
||||||
|
return max(preferred, key=lambda t: t.order).key
|
||||||
|
return max(usable, key=lambda t: t.order).key
|
||||||
|
|
||||||
|
|
||||||
def first_available_tier(preferred: str) -> str:
|
def first_available_tier(preferred: str) -> str:
|
||||||
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
|
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
|
||||||
tier = get_tier(preferred)
|
tier = get_tier(preferred)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ SWIFT = _register(
|
|||||||
order=2,
|
order=2,
|
||||||
name="신속",
|
name="신속",
|
||||||
tagline="빠르면서 인식률은 최상급",
|
tagline="빠르면서 인식률은 최상급",
|
||||||
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
|
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
|
||||||
mt=MTSpec(
|
mt=MTSpec(
|
||||||
"entai2965/nllb-200-distilled-600M-ctranslate2",
|
"entai2965/nllb-200-distilled-600M-ctranslate2",
|
||||||
MTBackend.CTRANSLATE2,
|
MTBackend.CTRANSLATE2,
|
||||||
@@ -140,7 +140,7 @@ BALANCE = _register(
|
|||||||
order=3,
|
order=3,
|
||||||
name="균형",
|
name="균형",
|
||||||
tagline="기본값 · 속도와 품질의 중간",
|
tagline="기본값 · 속도와 품질의 중간",
|
||||||
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
||||||
mt=MTSpec(
|
mt=MTSpec(
|
||||||
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
|
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
|
||||||
MTBackend.CTRANSLATE2,
|
MTBackend.CTRANSLATE2,
|
||||||
@@ -160,7 +160,7 @@ PRECISION = _register(
|
|||||||
order=4,
|
order=4,
|
||||||
name="정밀",
|
name="정밀",
|
||||||
tagline="추천 · 현 시점 최고 번역 품질",
|
tagline="추천 · 현 시점 최고 번역 품질",
|
||||||
asr=AsrSpec("Systran/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
|
||||||
mt=MTSpec(
|
mt=MTSpec(
|
||||||
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
|
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
|
||||||
MTBackend.TRANSFORMERS,
|
MTBackend.TRANSFORMERS,
|
||||||
|
|||||||
@@ -162,8 +162,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||||
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
||||||
|
|
||||||
|
self.subtitle_page.edit_mode_requested.connect(self.overlay.set_edit_mode)
|
||||||
|
|
||||||
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
||||||
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
||||||
|
self.overlay.edit_mode_changed.connect(self.subtitle_page.set_edit_mode)
|
||||||
|
self.overlay.font_size_changed.connect(self._on_overlay_font_resized)
|
||||||
self.overlay.closed.connect(
|
self.overlay.closed.connect(
|
||||||
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||||
)
|
)
|
||||||
@@ -278,6 +282,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.subtitle_page.sync_from_config()
|
self.subtitle_page.sync_from_config()
|
||||||
self._schedule_save()
|
self._schedule_save()
|
||||||
|
|
||||||
|
def _on_overlay_font_resized(self, size: int) -> None:
|
||||||
|
"""모서리를 잡아 크기를 바꾸면 글자 크기도 같이 바뀐다."""
|
||||||
|
self.subtitle_page.set_font_size(size)
|
||||||
|
self._schedule_save()
|
||||||
|
|
||||||
def _on_glossary_changed(self) -> None:
|
def _on_glossary_changed(self) -> None:
|
||||||
self.engine.reload_glossary()
|
self.engine.reload_glossary()
|
||||||
self._schedule_save()
|
self._schedule_save()
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
||||||
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
||||||
|
|
||||||
조작:
|
평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
|
||||||
* 드래그 — 위치 이동
|
때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
|
||||||
* 우하단 모서리 — 크기 조절
|
|
||||||
* 휠 — 글자 크기 조절
|
위치 조정 모드에서:
|
||||||
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
* 드래그 — 위치 이동
|
||||||
|
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
|
||||||
|
* 휠 — 글자 크기 조절
|
||||||
|
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -38,17 +41,16 @@ def paint_subtitle(
|
|||||||
source: str = "",
|
source: str = "",
|
||||||
partial: str = "",
|
partial: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
|
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다.
|
||||||
|
|
||||||
|
배경 상자는 창 전체가 아니라 **글자를 감싸는 크기**로 그린다. 창 전체를
|
||||||
|
칠하면 자막이 짧아도 커다란 띠가 남고, 그 상태에서는 여백을 키워도 글자만
|
||||||
|
안쪽으로 밀릴 뿐 '배경 크기'는 그대로다. 감싸도록 그려야 여백이 곧 배경
|
||||||
|
크기가 된다.
|
||||||
|
"""
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, 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]] = []
|
rows: list[tuple[str, QColor, int]] = []
|
||||||
if st.show_source and source and translated:
|
if st.show_source and source and translated:
|
||||||
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
||||||
@@ -61,33 +63,60 @@ def paint_subtitle(
|
|||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
margin = 18
|
pad = max(0, st.padding)
|
||||||
available = rect.width() - margin * 2
|
available = rect.width() - pad * 2
|
||||||
blocks: list[tuple[list[str], QColor, QFont]] = []
|
blocks: list[tuple[list[str], QColor, QFont]] = []
|
||||||
total_h = 0
|
total_h = 0
|
||||||
|
text_w = 0
|
||||||
for text, color, size in rows:
|
for text, color, size in rows:
|
||||||
font = QFont(st.font_family, size)
|
font = QFont(st.font_family, size)
|
||||||
font.setBold(st.bold)
|
font.setBold(st.bold)
|
||||||
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
|
fm = QFontMetrics(font)
|
||||||
|
lines = _wrap(text, fm, available, st.max_lines)
|
||||||
blocks.append((lines, color, font))
|
blocks.append((lines, color, font))
|
||||||
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
|
total_h += len(lines) * (fm.height() + st.line_spacing)
|
||||||
|
for line in lines:
|
||||||
|
text_w = max(text_w, fm.horizontalAdvance(line))
|
||||||
|
|
||||||
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
|
box = _content_box(rect, text_w, total_h, pad, st.align)
|
||||||
|
|
||||||
|
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(box, 12, 12)
|
||||||
|
|
||||||
|
y = box.y() + pad
|
||||||
for lines, color, font in blocks:
|
for lines, color, font in blocks:
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
fm = QFontMetrics(font)
|
fm = QFontMetrics(font)
|
||||||
for line in lines:
|
for line in lines:
|
||||||
width = fm.horizontalAdvance(line)
|
width = fm.horizontalAdvance(line)
|
||||||
if st.align == "left":
|
if st.align == "left":
|
||||||
x = rect.x() + margin
|
x = box.x() + pad
|
||||||
elif st.align == "right":
|
elif st.align == "right":
|
||||||
x = rect.right() - margin - width
|
x = box.right() - pad - width
|
||||||
else:
|
else:
|
||||||
x = rect.x() + (rect.width() - width) // 2
|
x = box.x() + (box.width() - width) // 2
|
||||||
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
||||||
y += fm.height() + st.line_spacing
|
y += fm.height() + st.line_spacing
|
||||||
|
|
||||||
|
|
||||||
|
def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect:
|
||||||
|
"""글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다."""
|
||||||
|
width = min(rect.width(), text_w + pad * 2)
|
||||||
|
height = min(rect.height(), text_h + pad * 2)
|
||||||
|
if align == "left":
|
||||||
|
x = rect.x()
|
||||||
|
elif align == "right":
|
||||||
|
x = rect.right() - width + 1
|
||||||
|
else:
|
||||||
|
x = rect.x() + (rect.width() - width) // 2
|
||||||
|
y = rect.y() + (rect.height() - height) // 2
|
||||||
|
return QRect(x, y, width, height)
|
||||||
|
|
||||||
|
|
||||||
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
||||||
"""외곽선 있는 글자.
|
"""외곽선 있는 글자.
|
||||||
|
|
||||||
@@ -145,6 +174,10 @@ class SubtitleOverlay(QWidget):
|
|||||||
geometry_changed = Signal(int, int, int, int)
|
geometry_changed = Signal(int, int, int, int)
|
||||||
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
||||||
visibility_changed = Signal(bool)
|
visibility_changed = Signal(bool)
|
||||||
|
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
|
||||||
|
font_size_changed = Signal(int)
|
||||||
|
#: 위치 조정 모드가 켜지고 꺼질 때
|
||||||
|
edit_mode_changed = Signal(bool)
|
||||||
closed = Signal()
|
closed = Signal()
|
||||||
|
|
||||||
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
||||||
@@ -156,7 +189,13 @@ class SubtitleOverlay(QWidget):
|
|||||||
self._partial_text = ""
|
self._partial_text = ""
|
||||||
self._source_text = ""
|
self._source_text = ""
|
||||||
self._drag_from: QPoint | None = None
|
self._drag_from: QPoint | None = None
|
||||||
self._resize_from: QRect | None = None
|
#: 위치 조정 모드. 설정에 저장하지 않는다 — 다음 실행 때는 다시 잠긴
|
||||||
|
#: 상태로 시작해야 게임 중에 자막을 실수로 끌고 다니지 않는다.
|
||||||
|
self._edit_mode = False
|
||||||
|
self._resize_corner = ""
|
||||||
|
self._resize_geo = QRect()
|
||||||
|
self._resize_mouse = QPoint()
|
||||||
|
self._resize_font = 0
|
||||||
|
|
||||||
self._fade = QTimer(self)
|
self._fade = QTimer(self)
|
||||||
self._fade.setSingleShot(True)
|
self._fade.setSingleShot(True)
|
||||||
@@ -181,8 +220,11 @@ class SubtitleOverlay(QWidget):
|
|||||||
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
||||||
)
|
)
|
||||||
self.setWindowFlags(flags)
|
self.setWindowFlags(flags)
|
||||||
|
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
|
||||||
|
# 창을 그냥 지나가서 잡을 수가 없다.
|
||||||
self.setAttribute(
|
self.setAttribute(
|
||||||
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||||
|
cfg.click_through and not self._edit_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
||||||
@@ -192,6 +234,50 @@ class SubtitleOverlay(QWidget):
|
|||||||
self.setVisible(cfg.visible)
|
self.setVisible(cfg.visible)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
# --- 위치 조정 모드 --------------------------------------------------
|
||||||
|
@property
|
||||||
|
def edit_mode(self) -> bool:
|
||||||
|
return self._edit_mode
|
||||||
|
|
||||||
|
def set_edit_mode(self, on: bool) -> None:
|
||||||
|
"""자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다.
|
||||||
|
|
||||||
|
평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이
|
||||||
|
없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이
|
||||||
|
동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다.
|
||||||
|
"""
|
||||||
|
if on == self._edit_mode:
|
||||||
|
return
|
||||||
|
self._edit_mode = on
|
||||||
|
if on and not self.isVisible():
|
||||||
|
self.setVisible(True)
|
||||||
|
self.setAttribute(
|
||||||
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||||
|
self.overlay_cfg.click_through and not on,
|
||||||
|
)
|
||||||
|
self.setCursor(
|
||||||
|
Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor
|
||||||
|
)
|
||||||
|
if not on:
|
||||||
|
self._drag_from = None
|
||||||
|
self._resize_corner = ""
|
||||||
|
self.update()
|
||||||
|
self.edit_mode_changed.emit(on)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _interactive(self) -> bool:
|
||||||
|
return self._edit_mode or not self.overlay_cfg.locked
|
||||||
|
|
||||||
|
def _corner_at(self, pos: QPoint) -> str:
|
||||||
|
"""모서리 손잡이를 집었는지. 집었으면 어느 모서리인지."""
|
||||||
|
left = pos.x() <= GRIP
|
||||||
|
right = pos.x() >= self.width() - GRIP
|
||||||
|
top = pos.y() <= GRIP
|
||||||
|
bottom = pos.y() >= self.height() - GRIP
|
||||||
|
if (left or right) and (top or bottom):
|
||||||
|
return ("top" if top else "bottom") + ("left" if left else "right")
|
||||||
|
return ""
|
||||||
|
|
||||||
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
||||||
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
||||||
cfg = self.overlay_cfg
|
cfg = self.overlay_cfg
|
||||||
@@ -248,53 +334,94 @@ class SubtitleOverlay(QWidget):
|
|||||||
self._source_text,
|
self._source_text,
|
||||||
self._partial_text,
|
self._partial_text,
|
||||||
)
|
)
|
||||||
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
|
if self._edit_mode:
|
||||||
self._draw_placeholder(painter)
|
self._draw_edit_chrome(painter)
|
||||||
self._draw_grip(painter)
|
|
||||||
|
|
||||||
def _draw_placeholder(self, painter) -> None:
|
def _draw_edit_chrome(self, painter) -> None:
|
||||||
painter.setPen(QColor(255, 255, 255, 90))
|
"""위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
|
||||||
font = QFont(self.style_cfg.font_family, 13)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
painter.setFont(font)
|
painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
|
||||||
painter.drawText(
|
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
|
||||||
self.rect(),
|
|
||||||
Qt.AlignmentFlag.AlignCenter,
|
|
||||||
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _draw_grip(self, painter) -> None:
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
|
painter.setBrush(QColor(90, 170, 255, 235))
|
||||||
return
|
for rect in self._corner_handles():
|
||||||
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
|
painter.drawRect(rect)
|
||||||
|
|
||||||
|
if not (self._final_text or self._partial_text):
|
||||||
|
painter.setPen(QColor(255, 255, 255, 170))
|
||||||
|
font = QFont(self.style_cfg.font_family, 13)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.drawText(
|
||||||
|
self.rect(),
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"끌어서 이동 · 모서리를 잡아 크기 조절 (글자 크기도 같이 바뀝니다)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _corner_handles(self) -> list[QRect]:
|
||||||
w, h = self.width(), self.height()
|
w, h = self.width(), self.height()
|
||||||
for offset in (5, 10, 15):
|
return [
|
||||||
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
|
QRect(0, 0, GRIP, GRIP),
|
||||||
|
QRect(w - GRIP, 0, GRIP, GRIP),
|
||||||
|
QRect(0, h - GRIP, GRIP, GRIP),
|
||||||
|
QRect(w - GRIP, h - GRIP, GRIP, GRIP),
|
||||||
|
]
|
||||||
|
|
||||||
# --- 마우스 ---------------------------------------------------------
|
# --- 마우스 ---------------------------------------------------------
|
||||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||||
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
|
if not self._interactive or event.button() != Qt.MouseButton.LeftButton:
|
||||||
return
|
return
|
||||||
pos = event.position().toPoint()
|
corner = self._corner_at(event.position().toPoint())
|
||||||
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
|
if corner:
|
||||||
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
|
self._resize_corner = corner
|
||||||
|
self._resize_geo = QRect(self.geometry())
|
||||||
|
self._resize_mouse = event.globalPosition().toPoint()
|
||||||
|
self._resize_font = self.style_cfg.font_size
|
||||||
else:
|
else:
|
||||||
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
||||||
|
|
||||||
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
||||||
if self._resize_from is not None:
|
if self._resize_corner:
|
||||||
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
|
self._resize_to(event.globalPosition().toPoint())
|
||||||
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:
|
elif self._drag_from is not None:
|
||||||
self.move(event.globalPosition().toPoint() - self._drag_from)
|
self.move(event.globalPosition().toPoint() - self._drag_from)
|
||||||
|
|
||||||
|
def _resize_to(self, mouse: QPoint) -> None:
|
||||||
|
"""잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다.
|
||||||
|
|
||||||
|
글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면
|
||||||
|
여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다.
|
||||||
|
"""
|
||||||
|
start = self._resize_geo
|
||||||
|
dx = mouse.x() - self._resize_mouse.x()
|
||||||
|
dy = mouse.y() - self._resize_mouse.y()
|
||||||
|
left, top = start.left(), start.top()
|
||||||
|
right, bottom = start.right(), start.bottom()
|
||||||
|
|
||||||
|
if "left" in self._resize_corner:
|
||||||
|
left = min(left + dx, right - self.minimumWidth())
|
||||||
|
else:
|
||||||
|
right = max(right + dx, left + self.minimumWidth())
|
||||||
|
if "top" in self._resize_corner:
|
||||||
|
top = min(top + dy, bottom - self.minimumHeight())
|
||||||
|
else:
|
||||||
|
bottom = max(bottom + dy, top + self.minimumHeight())
|
||||||
|
|
||||||
|
rect = QRect(QPoint(left, top), QPoint(right, bottom))
|
||||||
|
self.setGeometry(rect)
|
||||||
|
|
||||||
|
scaled = round(self._resize_font * rect.height() / max(1, start.height()))
|
||||||
|
size = max(12, min(120, scaled))
|
||||||
|
if size != self.style_cfg.font_size:
|
||||||
|
self.style_cfg.font_size = size
|
||||||
|
self.font_size_changed.emit(size)
|
||||||
|
self.update()
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
||||||
if self._drag_from is None and self._resize_from is None:
|
if self._drag_from is None and not self._resize_corner:
|
||||||
return
|
return
|
||||||
self._drag_from = None
|
self._drag_from = None
|
||||||
self._resize_from = None
|
self._resize_corner = ""
|
||||||
|
|
||||||
cfg = self.overlay_cfg
|
cfg = self.overlay_cfg
|
||||||
g = self.geometry()
|
g = self.geometry()
|
||||||
@@ -309,10 +436,13 @@ class SubtitleOverlay(QWidget):
|
|||||||
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
||||||
|
|
||||||
def wheelEvent(self, event) -> None: # noqa: N802
|
def wheelEvent(self, event) -> None: # noqa: N802
|
||||||
if self.overlay_cfg.locked:
|
if not self._interactive:
|
||||||
return
|
return
|
||||||
step = 2 if event.angleDelta().y() > 0 else -2
|
step = 2 if event.angleDelta().y() > 0 else -2
|
||||||
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
|
size = max(12, min(120, self.style_cfg.font_size + step))
|
||||||
|
if size != self.style_cfg.font_size:
|
||||||
|
self.style_cfg.font_size = size
|
||||||
|
self.font_size_changed.emit(size)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def contextMenuEvent(self, event) -> None: # noqa: N802
|
def contextMenuEvent(self, event) -> None: # noqa: N802
|
||||||
@@ -343,7 +473,11 @@ class SubtitleOverlay(QWidget):
|
|||||||
|
|
||||||
def _set_click_through(self, value: bool) -> None:
|
def _set_click_through(self, value: bool) -> None:
|
||||||
self.overlay_cfg.click_through = value
|
self.overlay_cfg.click_through = value
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
|
# 위치 조정 중이면 통과를 켜지 않는다. 켜는 순간 창을 못 잡게 된다.
|
||||||
|
self.setAttribute(
|
||||||
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
||||||
|
value and not self._edit_mode,
|
||||||
|
)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _set_always_on_top(self, value: bool) -> None:
|
def _set_always_on_top(self, value: bool) -> None:
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ...audio import capture_capabilities, list_sources
|
from ...audio import (
|
||||||
|
SOURCE_MODE_INPUT,
|
||||||
|
SOURCE_MODE_OUTPUT,
|
||||||
|
capture_capabilities,
|
||||||
|
list_sources,
|
||||||
|
)
|
||||||
from ...config import AppConfig
|
from ...config import AppConfig
|
||||||
from ...constants import LANGUAGES
|
from ...constants import LANGUAGES
|
||||||
from ...core.events import EngineState, EngineStatus, TranslationLine
|
from ...core.events import EngineState, EngineStatus, TranslationLine
|
||||||
@@ -65,12 +70,34 @@ class HomePage(QWidget):
|
|||||||
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
|
||||||
layout.addWidget(self.source_combo, 1)
|
layout.addWidget(self.source_combo, 1)
|
||||||
|
|
||||||
|
# 마이크 등 입력 장치는 같은 목록에 섞지 않고 전환해서 보여준다.
|
||||||
|
# 한 목록에 다 넣으면 정작 찾으려던 게임이 라인입력들 사이에 파묻힌다.
|
||||||
|
self.mode_button = QPushButton()
|
||||||
|
self.mode_button.setMinimumWidth(170)
|
||||||
|
self.mode_button.clicked.connect(self._toggle_source_mode)
|
||||||
|
layout.addWidget(self.mode_button)
|
||||||
|
|
||||||
refresh = QPushButton("새로고침")
|
refresh = QPushButton("새로고침")
|
||||||
refresh.clicked.connect(self.refresh_sources)
|
refresh.clicked.connect(self.refresh_sources)
|
||||||
layout.addWidget(refresh)
|
layout.addWidget(refresh)
|
||||||
card.add(row)
|
card.add(row)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _mode(self) -> str:
|
||||||
|
return (
|
||||||
|
SOURCE_MODE_INPUT
|
||||||
|
if self.config.audio.source_mode == SOURCE_MODE_INPUT
|
||||||
|
else SOURCE_MODE_OUTPUT
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_source_mode(self) -> None:
|
||||||
|
self.config.audio.source_mode = (
|
||||||
|
SOURCE_MODE_OUTPUT if self._mode == SOURCE_MODE_INPUT else SOURCE_MODE_INPUT
|
||||||
|
)
|
||||||
|
self.refresh_sources()
|
||||||
|
self.config_changed.emit()
|
||||||
|
|
||||||
def _build_control_card(self) -> Card:
|
def _build_control_card(self) -> Card:
|
||||||
card = Card()
|
card = Card()
|
||||||
row = QWidget()
|
row = QWidget()
|
||||||
@@ -145,29 +172,49 @@ class HomePage(QWidget):
|
|||||||
# --- 동작 -----------------------------------------------------------
|
# --- 동작 -----------------------------------------------------------
|
||||||
def refresh_sources(self) -> None:
|
def refresh_sources(self) -> None:
|
||||||
caps = capture_capabilities()
|
caps = capture_capabilities()
|
||||||
self._sources = list_sources()
|
mode = self._mode
|
||||||
|
self._sources = list_sources(mode)
|
||||||
|
|
||||||
|
icons = {"process": "🎮", "device": "🔊", "input": "🎤"}
|
||||||
self.source_combo.blockSignals(True)
|
self.source_combo.blockSignals(True)
|
||||||
self.source_combo.clear()
|
self.source_combo.clear()
|
||||||
for src in self._sources:
|
for src in self._sources:
|
||||||
prefix = "🎮" if src.kind == "process" else "🔊"
|
prefix = icons.get(src.kind, "🔊")
|
||||||
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
|
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
|
||||||
if not self._sources:
|
if not self._sources:
|
||||||
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
|
self.source_combo.addItem(
|
||||||
|
"마이크 등 입력 장치를 찾지 못했습니다"
|
||||||
|
if mode == SOURCE_MODE_INPUT
|
||||||
|
else "사용 가능한 오디오 대상이 없습니다",
|
||||||
|
None,
|
||||||
|
)
|
||||||
self._select_saved_source()
|
self._select_saved_source()
|
||||||
self.source_combo.blockSignals(False)
|
self.source_combo.blockSignals(False)
|
||||||
self._on_source_changed()
|
self._on_source_changed()
|
||||||
|
|
||||||
if caps["process"]:
|
# 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다.
|
||||||
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
|
if mode == SOURCE_MODE_INPUT:
|
||||||
elif caps["device"]:
|
self.mode_button.setText("프로그램 소리에서 고르기")
|
||||||
|
self.mode_button.setToolTip("게임 등 프로그램과 출력 장치 목록으로 돌아갑니다.")
|
||||||
msg = (
|
msg = (
|
||||||
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
|
"마이크 등 입력 장치로 듣습니다. 통화 상대 목소리나 내가 말하는 것을 "
|
||||||
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
|
"번역할 때 쓰세요."
|
||||||
"프로그램 단위로 분리됩니다."
|
if caps["input"]
|
||||||
|
else "이 환경에서는 입력 장치를 열 수 없습니다. Windows에서 실행하세요."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
|
self.mode_button.setText("마이크 입력에서 고르기")
|
||||||
|
self.mode_button.setToolTip("목록을 마이크 등 입력 장치로 바꿉니다.")
|
||||||
|
if caps["process"]:
|
||||||
|
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
|
||||||
|
elif caps["device"]:
|
||||||
|
msg = (
|
||||||
|
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
|
||||||
|
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
|
||||||
|
"프로그램 단위로 분리됩니다."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
|
||||||
self.capability_label.setText(msg)
|
self.capability_label.setText(msg)
|
||||||
|
|
||||||
def _select_saved_source(self) -> None:
|
def _select_saved_source(self) -> None:
|
||||||
@@ -176,7 +223,11 @@ class HomePage(QWidget):
|
|||||||
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
|
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
|
||||||
self.source_combo.setCurrentIndex(i)
|
self.source_combo.setCurrentIndex(i)
|
||||||
return
|
return
|
||||||
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
|
if (
|
||||||
|
cfg.backend in ("device", "input")
|
||||||
|
and src.kind == cfg.backend
|
||||||
|
and src.device_index == cfg.device_index
|
||||||
|
):
|
||||||
self.source_combo.setCurrentIndex(i)
|
self.source_combo.setCurrentIndex(i)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -190,7 +241,7 @@ class HomePage(QWidget):
|
|||||||
cfg.target_pid = src.pid
|
cfg.target_pid = src.pid
|
||||||
cfg.target_process_name = src.label
|
cfg.target_process_name = src.label
|
||||||
else:
|
else:
|
||||||
cfg.backend = "device"
|
cfg.backend = src.kind # "device" 또는 "input"
|
||||||
cfg.device_index = src.device_index
|
cfg.device_index = src.device_index
|
||||||
cfg.target_pid = 0
|
cfg.target_pid = 0
|
||||||
self.config_changed.emit()
|
self.config_changed.emit()
|
||||||
@@ -226,7 +277,12 @@ class HomePage(QWidget):
|
|||||||
self.status_pill.set_status(status.message or status.state.value, kind)
|
self.status_pill.set_status(status.message or status.state.value, kind)
|
||||||
self.level_meter.set_level(status.level)
|
self.level_meter.set_level(status.level)
|
||||||
|
|
||||||
for widget in (self.source_combo, self.source_lang, self.target_lang):
|
for widget in (
|
||||||
|
self.source_combo,
|
||||||
|
self.mode_button,
|
||||||
|
self.source_lang,
|
||||||
|
self.target_lang,
|
||||||
|
):
|
||||||
widget.setEnabled(not running)
|
widget.setEnabled(not running)
|
||||||
self.speech_combo.setEnabled(
|
self.speech_combo.setEnabled(
|
||||||
not running and self.config.models.target_lang == "ko"
|
not running and self.config.models.target_lang == "ko"
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ...config import AppConfig
|
from ...config import AppConfig
|
||||||
from ...models import Tier, detect_gpu, ordered_tiers
|
from ...models import Tier, detect_gpu, get_tier, ordered_tiers
|
||||||
from ...models.manager import GpuInfo, tier_availability
|
from ...models.manager import GpuInfo, recommended_tier, tier_availability
|
||||||
from ..theme import SPACING, palette
|
from ..theme import SPACING, palette
|
||||||
from ..widgets import Card, PageHeader
|
from ..widgets import Card, PageHeader
|
||||||
|
|
||||||
@@ -26,7 +26,13 @@ from ..widgets import Card, PageHeader
|
|||||||
class TierCard(QFrame):
|
class TierCard(QFrame):
|
||||||
"""티어 하나를 라디오 버튼 카드로."""
|
"""티어 하나를 라디오 버튼 카드로."""
|
||||||
|
|
||||||
def __init__(self, tier: Tier, gpu: GpuInfo, parent: QWidget | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
tier: Tier,
|
||||||
|
gpu: GpuInfo,
|
||||||
|
recommended: bool = False,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.tier = tier
|
self.tier = tier
|
||||||
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
|
||||||
@@ -49,8 +55,10 @@ class TierCard(QFrame):
|
|||||||
name = QLabel(f"{tier.order}. {tier.name}")
|
name = QLabel(f"{tier.order}. {tier.name}")
|
||||||
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
name.setStyleSheet("font-size: 16px; font-weight: 700;")
|
||||||
head.addWidget(name)
|
head.addWidget(name)
|
||||||
if tier.recommended:
|
# '추천'은 표에 박힌 값이 아니라 이 PC 기준으로 계산해서 붙인다.
|
||||||
head.addWidget(_badge("추천", p.accent))
|
# GPU 가 없는데 10GB 짜리에 추천 배지가 달려 있으면 안내가 아니라 함정이다.
|
||||||
|
if recommended:
|
||||||
|
head.addWidget(_badge("CPU 추천" if not gpu.available else "추천", p.accent))
|
||||||
if tier.best_after_finetune:
|
if tier.best_after_finetune:
|
||||||
head.addWidget(_badge("추가학습 최적", p.success))
|
head.addWidget(_badge("추가학습 최적", p.success))
|
||||||
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
|
||||||
@@ -106,6 +114,8 @@ class ModelsPage(QWidget):
|
|||||||
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
|
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
|
||||||
self._gpu = detect_gpu()
|
self._gpu = detect_gpu()
|
||||||
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
|
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
|
||||||
|
#: 이 PC 기준 추천 티어. GPU 가 없으면 CPU 에서 실제로 돌아가는 쪽을 권한다.
|
||||||
|
self._recommended = recommended_tier(self._gpu)
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -123,7 +133,7 @@ class ModelsPage(QWidget):
|
|||||||
self._group = QButtonGroup(self)
|
self._group = QButtonGroup(self)
|
||||||
self._group.setExclusive(True)
|
self._group.setExclusive(True)
|
||||||
for tier in ordered_tiers():
|
for tier in ordered_tiers():
|
||||||
card = TierCard(tier, self._gpu)
|
card = TierCard(tier, self._gpu, recommended=tier.key == self._recommended)
|
||||||
self._group.addButton(card.radio, tier.order)
|
self._group.addButton(card.radio, tier.order)
|
||||||
card.radio.toggled.connect(
|
card.radio.toggled.connect(
|
||||||
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
|
||||||
@@ -163,6 +173,10 @@ class ModelsPage(QWidget):
|
|||||||
self.device_combo = QComboBox()
|
self.device_combo = QComboBox()
|
||||||
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
self.device_combo.addItem("GPU (CUDA)", "cuda")
|
||||||
self.device_combo.addItem("CPU", "cpu")
|
self.device_combo.addItem("CPU", "cpu")
|
||||||
|
# GPU 가 없으면 콤보를 비활성화만 하고 "GPU (CUDA)" 를 그대로 띄워두면
|
||||||
|
# 실제로는 CPU 로 도는데 화면은 GPU 라고 말하는 상태가 된다. 맞춰둔다.
|
||||||
|
if not gpu.available:
|
||||||
|
self.config.models.device = "cpu"
|
||||||
self.device_combo.setCurrentIndex(
|
self.device_combo.setCurrentIndex(
|
||||||
max(0, self.device_combo.findData(self.config.models.device))
|
max(0, self.device_combo.findData(self.config.models.device))
|
||||||
)
|
)
|
||||||
@@ -171,8 +185,42 @@ class ModelsPage(QWidget):
|
|||||||
layout.addWidget(self.device_combo)
|
layout.addWidget(self.device_combo)
|
||||||
|
|
||||||
card.add(row)
|
card.add(row)
|
||||||
|
|
||||||
|
if not gpu.available:
|
||||||
|
card.add(self._build_cpu_advice_row())
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
def _build_cpu_advice_row(self) -> QWidget:
|
||||||
|
"""GPU 를 못 찾았을 때 CPU 에서 실제로 쓸 만한 설정을 권한다."""
|
||||||
|
tier = get_tier(self._recommended)
|
||||||
|
row = QWidget()
|
||||||
|
row.setObjectName("Row")
|
||||||
|
layout = QHBoxLayout(row)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
|
||||||
|
advice = QLabel(
|
||||||
|
f"CPU 에서는 '{tier.order}. {tier.name}' 를 권합니다 — "
|
||||||
|
f"CPU 로 실시간 자막이 따라오는 가장 무거운 구성입니다. "
|
||||||
|
f"상위 티어는 한 문장에 수십 초가 걸려 실시간으로 쓸 수 없습니다."
|
||||||
|
)
|
||||||
|
advice.setObjectName("Hint")
|
||||||
|
advice.setWordWrap(True)
|
||||||
|
layout.addWidget(advice, 1)
|
||||||
|
|
||||||
|
apply_button = QPushButton("추천대로 맞추기")
|
||||||
|
apply_button.clicked.connect(self._apply_recommended)
|
||||||
|
layout.addWidget(apply_button)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _apply_recommended(self) -> None:
|
||||||
|
self.config.models.device = "cpu"
|
||||||
|
self.device_combo.blockSignals(True)
|
||||||
|
self.device_combo.setCurrentIndex(max(0, self.device_combo.findData("cpu")))
|
||||||
|
self.device_combo.blockSignals(False)
|
||||||
|
self._apply_selection(self._recommended)
|
||||||
|
self._on_tier_selected(get_tier(self._recommended))
|
||||||
|
|
||||||
def _build_lora_card(self) -> Card:
|
def _build_lora_card(self) -> Card:
|
||||||
card = Card(
|
card = Card(
|
||||||
"추가학습 어댑터 (선택)",
|
"추가학습 어댑터 (선택)",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ class SubtitlePage(QWidget):
|
|||||||
overlay_toggled = Signal(bool)
|
overlay_toggled = Signal(bool)
|
||||||
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
|
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
|
||||||
placement_changed = Signal()
|
placement_changed = Signal()
|
||||||
|
#: '자막 위치 조정' 버튼 — 오버레이를 조정 가능한 상태로 열고 닫는다
|
||||||
|
edit_mode_requested = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
def __init__(self, config: AppConfig, parent: QWidget | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -88,7 +90,7 @@ class SubtitlePage(QWidget):
|
|||||||
card = Card(
|
card = Card(
|
||||||
"표시 위치",
|
"표시 위치",
|
||||||
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
|
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
|
||||||
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
|
"칸에 없는 자리에 두고 싶으면 아래 '자막 위치 조정'으로 직접 옮기세요.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- on/off + 단축키 안내 ---
|
# --- on/off + 단축키 안내 ---
|
||||||
@@ -159,9 +161,55 @@ class SubtitlePage(QWidget):
|
|||||||
margin_layout.addStretch(1)
|
margin_layout.addStretch(1)
|
||||||
card.add(margin_row)
|
card.add(margin_row)
|
||||||
|
|
||||||
|
# --- 직접 옮기기 ---
|
||||||
|
# 자막 창은 기본적으로 잠겨 있다. 게임 중에 잘못 건드려 끌고 다니는
|
||||||
|
# 사고를 막기 위해서다. 옮기거나 크기를 바꿀 때만 여기서 연다.
|
||||||
|
adjust_row = QWidget()
|
||||||
|
adjust_layout = QHBoxLayout(adjust_row)
|
||||||
|
adjust_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
adjust_layout.setSpacing(10)
|
||||||
|
|
||||||
|
self.adjust_button = QPushButton("자막 위치 조정")
|
||||||
|
self.adjust_button.setCheckable(True)
|
||||||
|
self.adjust_button.setMinimumWidth(150)
|
||||||
|
self.adjust_button.toggled.connect(self.edit_mode_requested.emit)
|
||||||
|
adjust_layout.addWidget(self.adjust_button)
|
||||||
|
|
||||||
|
self.adjust_hint = QLabel()
|
||||||
|
self.adjust_hint.setObjectName("Hint")
|
||||||
|
self.adjust_hint.setWordWrap(True)
|
||||||
|
adjust_layout.addWidget(self.adjust_hint, 1)
|
||||||
|
card.add(adjust_row)
|
||||||
|
self.set_edit_mode(False)
|
||||||
|
|
||||||
self.refresh_screens()
|
self.refresh_screens()
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
def set_edit_mode(self, on: bool) -> None:
|
||||||
|
"""오버레이의 위치 조정 상태를 버튼과 안내에 반영한다."""
|
||||||
|
self.adjust_button.blockSignals(True)
|
||||||
|
self.adjust_button.setChecked(on)
|
||||||
|
self.adjust_button.blockSignals(False)
|
||||||
|
self.adjust_button.setText("조정 끝내기" if on else "자막 위치 조정")
|
||||||
|
self.adjust_button.setObjectName("Danger" if on else "")
|
||||||
|
self.adjust_button.style().polish(self.adjust_button)
|
||||||
|
self.adjust_hint.setText(
|
||||||
|
"자막 창을 끌어서 옮기고, 네 모서리를 잡아 크기를 바꾸세요. "
|
||||||
|
"크기를 바꾸면 글자 크기도 같이 따라옵니다."
|
||||||
|
if on
|
||||||
|
else "평소에는 자막이 잠겨 있어 게임 중에 실수로 움직이지 않습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_font_size(self, size: int) -> None:
|
||||||
|
"""자막 창에서 크기를 바꿨을 때 설정 화면 숫자를 맞춘다."""
|
||||||
|
if size == self.size_spin.value():
|
||||||
|
return
|
||||||
|
self.size_spin.blockSignals(True)
|
||||||
|
self.size_spin.setValue(size)
|
||||||
|
self.size_spin.blockSignals(False)
|
||||||
|
self.config.subtitle.font_size = size
|
||||||
|
self._sync_preview()
|
||||||
|
|
||||||
def _build_preview_card(self) -> Card:
|
def _build_preview_card(self) -> Card:
|
||||||
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
|
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
|
||||||
self.preview = SubtitlePreview(self.config.subtitle)
|
self.preview = SubtitlePreview(self.config.subtitle)
|
||||||
@@ -238,6 +286,17 @@ class SubtitlePage(QWidget):
|
|||||||
layout.addWidget(self.opacity_value)
|
layout.addWidget(self.opacity_value)
|
||||||
form.addRow("배경 투명도", opacity_row)
|
form.addRow("배경 투명도", opacity_row)
|
||||||
|
|
||||||
|
self.padding_spin = QSpinBox()
|
||||||
|
self.padding_spin.setRange(0, 80)
|
||||||
|
self.padding_spin.setSuffix(" px")
|
||||||
|
self.padding_spin.setValue(st.padding)
|
||||||
|
self.padding_spin.setToolTip(
|
||||||
|
"배경 상자는 글자를 감싸는 크기로 그려집니다. 이 값이 글자와 상자 "
|
||||||
|
"가장자리 사이 간격, 곧 배경 크기를 정합니다."
|
||||||
|
)
|
||||||
|
self.padding_spin.valueChanged.connect(self._on_change)
|
||||||
|
form.addRow("배경 여백", self.padding_spin)
|
||||||
|
|
||||||
self.align_combo = QComboBox()
|
self.align_combo = QComboBox()
|
||||||
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
|
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
|
||||||
self.align_combo.addItem(label, value)
|
self.align_combo.addItem(label, value)
|
||||||
@@ -345,6 +404,7 @@ class SubtitlePage(QWidget):
|
|||||||
st.outline_width = self.outline_spin.value()
|
st.outline_width = self.outline_spin.value()
|
||||||
st.background_color = self.bg_color.color
|
st.background_color = self.bg_color.color
|
||||||
st.background_opacity = self.opacity_slider.value()
|
st.background_opacity = self.opacity_slider.value()
|
||||||
|
st.padding = self.padding_spin.value()
|
||||||
st.align = self.align_combo.currentData()
|
st.align = self.align_combo.currentData()
|
||||||
st.max_lines = self.lines_spin.value()
|
st.max_lines = self.lines_spin.value()
|
||||||
st.fade_out_ms = self.fade_spin.value()
|
st.fade_out_ms = self.fade_spin.value()
|
||||||
|
|||||||
75
tests/test_audio_sources.py
Normal file
75
tests/test_audio_sources.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""캡처 대상 목록 — 출력(게임 소리)과 입력(마이크) 전환."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from livesub import audio
|
||||||
|
from livesub.audio import (
|
||||||
|
SOURCE_MODE_INPUT,
|
||||||
|
SOURCE_MODE_OUTPUT,
|
||||||
|
AudioSource,
|
||||||
|
CaptureError,
|
||||||
|
InputDeviceCapture,
|
||||||
|
create_capture,
|
||||||
|
list_sources,
|
||||||
|
)
|
||||||
|
from livesub.config import AudioConfig
|
||||||
|
|
||||||
|
MIC = AudioSource(kind="input", identifier="3", label="마이크 (Realtek)", detail="기본 입력 장치")
|
||||||
|
SPEAKER = AudioSource(kind="device", identifier="7", label="스피커", detail="기본 출력 장치")
|
||||||
|
GAME = AudioSource(kind="process", identifier="4242", label="Overwatch.exe")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_devices(monkeypatch):
|
||||||
|
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: True))
|
||||||
|
monkeypatch.setattr(InputDeviceCapture, "list_sources", staticmethod(lambda: [MIC]))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
audio.WasapiLoopbackCapture, "list_sources", staticmethod(lambda: [SPEAKER])
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
audio.ProcessLoopbackCapture, "available", staticmethod(lambda: True)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
audio.ProcessLoopbackCapture, "list_sources", staticmethod(lambda: [GAME])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_기본은_프로그램과_출력_장치를_보여준다(fake_devices):
|
||||||
|
kinds = [s.kind for s in list_sources()]
|
||||||
|
assert kinds == ["process", "device"]
|
||||||
|
assert list_sources() == list_sources(SOURCE_MODE_OUTPUT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력_모드는_마이크만_보여준다(fake_devices):
|
||||||
|
"""두 목록을 섞으면 찾으려던 게임이 라인입력 사이에 파묻힌다."""
|
||||||
|
sources = list_sources(SOURCE_MODE_INPUT)
|
||||||
|
assert [s.kind for s in sources] == ["input"]
|
||||||
|
assert sources[0].label == "마이크 (Realtek)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력_장치도_장치_번호를_준다():
|
||||||
|
assert MIC.device_index == 3
|
||||||
|
assert MIC.pid == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력_백엔드로_캡처를_만든다(fake_devices):
|
||||||
|
cfg = AudioConfig(backend="input", device_index=3)
|
||||||
|
capture = create_capture(cfg)
|
||||||
|
assert isinstance(capture, InputDeviceCapture)
|
||||||
|
assert capture.device_index == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력_장치를_못_열면_이유를_알려준다(monkeypatch):
|
||||||
|
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: False))
|
||||||
|
with pytest.raises(CaptureError, match="입력 장치"):
|
||||||
|
create_capture(AudioConfig(backend="input", device_index=3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_입력_가능_여부가_환경_보고에_들어간다():
|
||||||
|
assert "input" in audio.capture_capabilities()
|
||||||
|
|
||||||
|
|
||||||
|
def test_고른_목록_쪽을_설정에_기억한다():
|
||||||
|
assert AudioConfig().source_mode == SOURCE_MODE_OUTPUT
|
||||||
37
tests/test_model_repos.py
Normal file
37
tests/test_model_repos.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""티어에 적힌 Hugging Face 저장소가 실제로 로그인 없이 받아지는지 확인한다.
|
||||||
|
|
||||||
|
저장소 이름이 틀리면 사용자 PC에서 "번역 시작"을 누를 때 401/404 로 처음 드러난다.
|
||||||
|
(v0.3.0-test4: 존재하지 않는 Systran/faster-whisper-large-v3-turbo 를 가리키고 있었다.)
|
||||||
|
네트워크가 없으면 건너뛴다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from livesub.models.tiers import TIERS
|
||||||
|
|
||||||
|
huggingface_hub = pytest.importorskip("huggingface_hub")
|
||||||
|
|
||||||
|
|
||||||
|
def _repos() -> list[str]:
|
||||||
|
return sorted({r for t in TIERS.values() for r in (t.asr.repo, t.mt.repo)})
|
||||||
|
|
||||||
|
|
||||||
|
def _online() -> bool:
|
||||||
|
try:
|
||||||
|
huggingface_hub.HfApi().model_info("Systran/faster-whisper-small", token=False)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("repo", _repos())
|
||||||
|
def test_repo_is_public_and_has_weights(repo):
|
||||||
|
if not _online():
|
||||||
|
pytest.skip("Hugging Face 에 접속할 수 없음")
|
||||||
|
info = huggingface_hub.HfApi().model_info(repo, token=False)
|
||||||
|
assert not info.private
|
||||||
|
assert not info.gated
|
||||||
|
files = {s.rfilename for s in info.siblings}
|
||||||
|
assert files & {"model.bin", "model.safetensors"} or any(
|
||||||
|
f.endswith(".safetensors") for f in files
|
||||||
|
), f"{repo}: 가중치 파일이 없음 ({sorted(files)})"
|
||||||
@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
|
|||||||
assert tier_availability(TIERS[key])[0]
|
assert tier_availability(TIERS[key])[0]
|
||||||
finally:
|
finally:
|
||||||
mgr.detect_gpu = original
|
mgr.detect_gpu = original
|
||||||
|
|
||||||
|
|
||||||
|
def test_진입_스크립트는_패키지_없이도_임포트된다():
|
||||||
|
"""PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다.
|
||||||
|
|
||||||
|
그래서 그 파일에 상대 임포트가 하나라도 있으면 exe 가 켜지자마자
|
||||||
|
`attempted relative import with no known parent package` 로 죽는다.
|
||||||
|
실제로 그 상태의 exe 가 릴리스까지 나갔고, "8초 뒤에도 살아 있으면 통과"
|
||||||
|
였던 점검은 오류창이 떠 있는 것을 정상으로 봤다.
|
||||||
|
|
||||||
|
`runpy.run_path` 가 바로 그 조건(패키지 문맥 없음)을 만들어준다.
|
||||||
|
run_name 을 `__main__` 이 아닌 것으로 주면 GUI 는 뜨지 않고 임포트만 돈다.
|
||||||
|
"""
|
||||||
|
import runpy
|
||||||
|
|
||||||
|
root = SPEC.resolve().parents[1]
|
||||||
|
entry = root / "src" / "livesub" / "__main__.py"
|
||||||
|
# spec 이 실제로 이 파일을 진입점으로 쓰는지 확인한다. 아니면 엉뚱한 파일을
|
||||||
|
# 지키고 있는 셈이 된다.
|
||||||
|
assert '"livesub" / "__main__.py"' in SPEC.read_text(encoding="utf-8")
|
||||||
|
runpy.run_path(str(entry), run_name="__livesub_frozen_probe__")
|
||||||
|
|||||||
149
tests/test_subtitle_box.py
Normal file
149
tests/test_subtitle_box.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""자막 배경 상자(padding)와 위치 조정 모드."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPoint, QRect # noqa: E402
|
||||||
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
from livesub.config import OverlayConfig, SubtitleStyle # noqa: E402
|
||||||
|
from livesub.ui.overlay import SubtitleOverlay, _content_box # noqa: E402
|
||||||
|
|
||||||
|
WINDOW = QRect(0, 0, 1000, 400)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def qapp():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
# --- 배경 상자 -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_배경은_글자를_감싼다():
|
||||||
|
"""창 전체가 아니라 글자 크기에 맞춰야 여백이 곧 배경 크기가 된다."""
|
||||||
|
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align="center")
|
||||||
|
assert box.width() == 300 + 18 * 2
|
||||||
|
assert box.height() == 60 + 18 * 2
|
||||||
|
assert box.width() < WINDOW.width()
|
||||||
|
|
||||||
|
|
||||||
|
def test_여백을_키우면_배경이_커진다():
|
||||||
|
small = _content_box(WINDOW, 300, 60, pad=8, align="center")
|
||||||
|
large = _content_box(WINDOW, 300, 60, pad=40, align="center")
|
||||||
|
assert large.width() - small.width() == (40 - 8) * 2
|
||||||
|
assert large.height() - small.height() == (40 - 8) * 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_배경은_창_밖으로_넘지_않는다():
|
||||||
|
box = _content_box(WINDOW, text_w=5000, text_h=5000, pad=40, align="center")
|
||||||
|
assert box.width() == WINDOW.width()
|
||||||
|
assert box.height() == WINDOW.height()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("align", "expected_left"),
|
||||||
|
[("left", 0), ("center", (1000 - 336) // 2), ("right", 1000 - 336)],
|
||||||
|
)
|
||||||
|
def test_정렬에_따라_상자가_붙는_쪽이_달라진다(align, expected_left):
|
||||||
|
box = _content_box(WINDOW, text_w=300, text_h=60, pad=18, align=align)
|
||||||
|
assert box.width() == 336
|
||||||
|
assert abs(box.x() - expected_left) <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_기본_여백이_설정에_있다():
|
||||||
|
assert SubtitleStyle().padding > 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- 위치 조정 모드 --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def overlay(qapp):
|
||||||
|
cfg = OverlayConfig()
|
||||||
|
cfg.visible = False
|
||||||
|
widget = SubtitleOverlay(SubtitleStyle(), cfg)
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_기본은_잠겨_있어_움직이지_않는다(overlay):
|
||||||
|
"""게임 중에 자막을 실수로 끌고 다니는 사고를 막는 기본값이다."""
|
||||||
|
assert OverlayConfig().locked is True
|
||||||
|
assert overlay.edit_mode is False
|
||||||
|
assert overlay._interactive is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_위치_조정을_켜면_잡을_수_있다(overlay):
|
||||||
|
overlay.set_edit_mode(True)
|
||||||
|
assert overlay.edit_mode is True
|
||||||
|
assert overlay._interactive is True
|
||||||
|
# 클릭 통과가 켜져 있으면 마우스가 창을 그냥 지나가 잡을 수가 없다.
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
assert not overlay.testAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||||
|
|
||||||
|
overlay.set_edit_mode(False)
|
||||||
|
assert overlay._interactive is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_모서리를_집었는지_알아본다(overlay):
|
||||||
|
overlay.setGeometry(0, 0, 600, 200)
|
||||||
|
assert overlay._corner_at(QPoint(2, 2)) == "topleft"
|
||||||
|
assert overlay._corner_at(QPoint(598, 2)) == "topright"
|
||||||
|
assert overlay._corner_at(QPoint(2, 198)) == "bottomleft"
|
||||||
|
assert overlay._corner_at(QPoint(598, 198)) == "bottomright"
|
||||||
|
# 가운데는 이동용이지 크기 조절이 아니다
|
||||||
|
assert overlay._corner_at(QPoint(300, 100)) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_크기를_키우면_글자도_같이_커진다(overlay):
|
||||||
|
overlay.set_edit_mode(True)
|
||||||
|
overlay.setGeometry(100, 100, 600, 200)
|
||||||
|
overlay.style_cfg.font_size = 40
|
||||||
|
|
||||||
|
overlay._resize_corner = "bottomright"
|
||||||
|
overlay._resize_geo = QRect(overlay.geometry())
|
||||||
|
overlay._resize_mouse = QPoint(700, 300)
|
||||||
|
overlay._resize_font = 40
|
||||||
|
overlay._resize_to(QPoint(700, 500)) # 높이 200 -> 400
|
||||||
|
|
||||||
|
assert overlay.height() == 400
|
||||||
|
assert overlay.style_cfg.font_size == 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_반대쪽_모서리는_제자리에_있는다(overlay):
|
||||||
|
"""왼쪽 위를 끌면 오른쪽 아래는 움직이지 않아야 한다."""
|
||||||
|
overlay.set_edit_mode(True)
|
||||||
|
overlay.setGeometry(200, 200, 600, 200)
|
||||||
|
start = QRect(overlay.geometry())
|
||||||
|
|
||||||
|
overlay._resize_corner = "topleft"
|
||||||
|
overlay._resize_geo = start
|
||||||
|
overlay._resize_mouse = QPoint(200, 200)
|
||||||
|
overlay._resize_font = overlay.style_cfg.font_size
|
||||||
|
overlay._resize_to(QPoint(300, 250))
|
||||||
|
|
||||||
|
assert overlay.geometry().right() == start.right()
|
||||||
|
assert overlay.geometry().bottom() == start.bottom()
|
||||||
|
assert overlay.geometry().x() == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_최소_크기_아래로는_줄지_않는다(overlay):
|
||||||
|
overlay.set_edit_mode(True)
|
||||||
|
overlay.setGeometry(0, 0, 600, 200)
|
||||||
|
overlay._resize_corner = "bottomright"
|
||||||
|
overlay._resize_geo = QRect(overlay.geometry())
|
||||||
|
overlay._resize_mouse = QPoint(600, 200)
|
||||||
|
overlay._resize_font = overlay.style_cfg.font_size
|
||||||
|
overlay._resize_to(QPoint(-5000, -5000))
|
||||||
|
|
||||||
|
assert overlay.width() >= overlay.minimumWidth()
|
||||||
|
assert overlay.height() >= overlay.minimumHeight()
|
||||||
@@ -49,3 +49,21 @@ def test_unknown_key_falls_back_to_default():
|
|||||||
|
|
||||||
def test_default_tier_exists():
|
def test_default_tier_exists():
|
||||||
assert DEFAULT_TIER in TIERS
|
assert DEFAULT_TIER in TIERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_가_없으면_cpu_에서_도는_티어를_추천한다():
|
||||||
|
"""표에 박힌 '추천'은 GPU 가 넉넉할 때 얘기다. 없는 사람에게 10GB 짜리를
|
||||||
|
권하면 안내가 아니라 함정이 된다."""
|
||||||
|
from livesub.models.manager import GpuInfo, recommended_tier
|
||||||
|
|
||||||
|
key = recommended_tier(GpuInfo(False, reason="GPU 없음"))
|
||||||
|
tier = get_tier(key)
|
||||||
|
assert tier.mt.backend is MTBackend.CTRANSLATE2
|
||||||
|
assert tier.order == 1 # CPU 에서는 연산량이 그대로 지연이 된다
|
||||||
|
|
||||||
|
|
||||||
|
def test_vram_이_모자라면_추천도_내려간다():
|
||||||
|
from livesub.models.manager import GpuInfo, recommended_tier
|
||||||
|
|
||||||
|
small = get_tier(recommended_tier(GpuInfo(True, "RTX 5050", 7680, 7000)))
|
||||||
|
assert small.min_vram_gb <= 7.5
|
||||||
|
|||||||
Reference in New Issue
Block a user