feat: 자막 배경 여백 · 위치 조정 모드 · CPU 추천 · 마이크 입력
Some checks failed
Windows / verify (push) Has been cancelled

요청 네 가지를 넣는다.

1) 자막 배경 여백(padding)
   배경 상자를 창 전체가 아니라 **글자를 감싸는 크기**로 그리도록 바꿨다.
   창 전체를 칠하던 동안에는 여백을 키워도 글자만 안으로 밀릴 뿐 배경
   크기는 그대로여서, 설정 항목을 만들어도 의미가 없었다.

2) 평소 잠금 + '자막 위치 조정'
   locked 기본값을 True 로 바꾼다. 게임 중에 자막을 실수로 끌고 다니는
   사고를 막는 쪽이 기본이어야 한다. 자막 화면의 버튼으로 조정 모드를
   열면 그때만 이동·네 모서리 크기 조절·휠이 열린다. 조정 중에는 클릭
   통과를 강제로 끈다 — 켜둔 채로는 마우스가 창을 지나가 잡을 수 없다.
   크기를 바꾸면 글자 크기가 높이 비율을 그대로 따라간다. 상자만 커지고
   글자가 그대로면 여백만 늘어나 '자막을 키우는' 조작이 되지 않는다.
   조정 모드는 저장하지 않는다. 다음 실행은 다시 잠긴 상태로 시작한다.

3) GPU 가 없을 때 CPU 추천
   티어 표의 recommended 는 GPU 가 넉넉할 때를 전제한 값이라, 그대로
   쓰면 GPU 없는 사람에게 10GB 짜리를 권하게 된다. 환경을 보고 고르는
   recommended_tier() 를 만들고 배지도 그 결과로 붙인다. GPU 가 없으면
   CTranslate2 계열만 후보에 두고 가장 가벼운 쪽을 권한다 — LLM 계열은
   CPU 에서 한 문장에 수십 초가 걸려 실시간 자막이 되지 않는다.
   '추천대로 맞추기' 버튼과, 실제로는 CPU 로 도는데 화면은 GPU 라고
   표시하던 것도 같이 고친다. nvidia-smi 실패 문구가 명령줄을 통째로
   토해내던 것도 사람이 읽을 문장으로 바꿨다.

4) 마이크 등 입력 장치 선택
   InputDeviceCapture 를 추가하고 홈 화면에 목록 전환 버튼을 둔다.
   두 목록을 한 번에 섞지 않는다 — 다 합치면 정작 찾으려던 게임이
   라인입력들 사이에 파묻힌다. 버튼 글자는 '지금 상태'가 아니라
   '누르면 가는 곳'을 적는다. 고른 쪽은 설정에 기억한다.
This commit is contained in:
EJClaw
2026-09-29 13:27:13 +09:00
parent bec5e285bd
commit 0201c7b913
15 changed files with 807 additions and 88 deletions

View File

@@ -25,12 +25,14 @@
## 주요 기능
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다)
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)

View File

@@ -7,17 +7,25 @@ import logging
from ..config import AudioConfig
from .base import AudioSource, CaptureBackend, CaptureError
from .file_source import FileCapture
from .input_device import InputDeviceCapture
from .process_loopback import ProcessLoopbackCapture
from .segmenter import Segment, Segmenter, SegmenterConfig
from .wasapi_loopback import WasapiLoopbackCapture
log = logging.getLogger(__name__)
#: 홈 화면 목록이 무엇을 보여줄지.
SOURCE_MODE_OUTPUT = "output" # 게임 등 프로그램 소리 + 출력 장치
SOURCE_MODE_INPUT = "input" # 마이크 등 입력 장치
__all__ = [
"SOURCE_MODE_INPUT",
"SOURCE_MODE_OUTPUT",
"AudioSource",
"CaptureBackend",
"CaptureError",
"FileCapture",
"InputDeviceCapture",
"ProcessLoopbackCapture",
"Segment",
"Segmenter",
@@ -34,12 +42,20 @@ def capture_capabilities() -> dict[str, bool]:
return {
"process": ProcessLoopbackCapture.available(),
"device": WasapiLoopbackCapture.available(),
"input": InputDeviceCapture.available(),
"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] = []
if ProcessLoopbackCapture.available():
sources.extend(ProcessLoopbackCapture.list_sources())
@@ -53,6 +69,13 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
if backend == "file":
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
if want_process:
if ProcessLoopbackCapture.available():

View File

@@ -20,7 +20,8 @@ from ..constants import SAMPLE_RATE
class AudioSource:
"""UI에 노출되는 캡처 대상 한 줄."""
kind: str # "process" | "device"
#: "process"=특정 프로그램 소리 · "device"=출력 장치 루프백 · "input"=마이크 등 입력 장치
kind: str
identifier: str # pid 문자열 또는 장치 인덱스
label: str
detail: str = ""
@@ -32,7 +33,7 @@ class AudioSource:
@property
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):

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

View File

@@ -35,6 +35,9 @@ class SubtitleStyle:
outline_width: int = 3
background_color: str = "#000000"
background_opacity: int = 55 # 0-100
#: 글자와 배경 상자 가장자리 사이 여백. 배경 상자는 글자를 감싸는 크기로
#: 그려지므로 이 값이 곧 배경 크기를 정한다.
padding: int = 18
line_spacing: int = 6
max_lines: int = 2
show_source: bool = False # 원문 동시 표시
@@ -53,7 +56,9 @@ class OverlayConfig:
always_on_top: bool = True
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True
locked: bool = False
#: 기본은 잠금이다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는다.
#: 옮기거나 크기를 바꿀 때는 자막 화면의 '자막 위치 조정'을 켠다.
locked: bool = True
#: 자막 표시 on/off (단축키로 토글)
visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터)
@@ -66,7 +71,10 @@ class OverlayConfig:
@dataclass
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_process_name: str = ""
device_index: int = -1 # -1 = 기본 출력 장치 루프백

View File

@@ -2,7 +2,7 @@
from .asr import SpeechRecognizer, Transcript
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 (
Politeness,
SpeechLevel,
@@ -43,5 +43,6 @@ __all__ = [
"detect_gpu",
"get_tier",
"ordered_tiers",
"recommended_tier",
"to_casual",
]

View File

@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
from ..config import ModelConfig
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
log = logging.getLogger(__name__)
@@ -57,7 +57,10 @@ def detect_gpu() -> GpuInfo:
return GpuInfo(True, name, int(total), int(free),
reason="PyTorch 미설치 — 정보만 조회했습니다.")
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:
@@ -136,6 +139,32 @@ def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str
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:
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
tier = get_tier(preferred)

View File

@@ -162,8 +162,12 @@ class MainWindow(QMainWindow):
self.settings_page.restart_needed.connect(self._on_restart_needed)
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.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(
lambda: self.subtitle_page.overlay_check.setChecked(False)
)
@@ -278,6 +282,11 @@ class MainWindow(QMainWindow):
self.subtitle_page.sync_from_config()
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:
self.engine.reload_glossary()
self._schedule_save()

View File

@@ -3,11 +3,14 @@
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
조작:
* 드래그 — 위치 이동
* 우하단 모서리 — 크기 조절
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
위치 조정 모드에서:
* 드래그 — 위치 이동
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
"""
from __future__ import annotations
@@ -38,17 +41,16 @@ def paint_subtitle(
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)))
@@ -61,33 +63,60 @@ def paint_subtitle(
if not rows:
return
margin = 18
available = rect.width() - margin * 2
pad = max(0, st.padding)
available = rect.width() - pad * 2
blocks: list[tuple[list[str], QColor, QFont]] = []
total_h = 0
text_w = 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)
fm = QFontMetrics(font)
lines = _wrap(text, fm, available, st.max_lines)
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:
painter.setFont(font)
fm = QFontMetrics(font)
for line in lines:
width = fm.horizontalAdvance(line)
if st.align == "left":
x = rect.x() + margin
x = box.x() + pad
elif st.align == "right":
x = rect.right() - margin - width
x = box.right() - pad - width
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)
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:
"""외곽선 있는 글자.
@@ -145,6 +174,10 @@ class SubtitleOverlay(QWidget):
geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
font_size_changed = Signal(int)
#: 위치 조정 모드가 켜지고 꺼질 때
edit_mode_changed = Signal(bool)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
@@ -156,7 +189,13 @@ class SubtitleOverlay(QWidget):
self._partial_text = ""
self._source_text = ""
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.setSingleShot(True)
@@ -181,8 +220,11 @@ class SubtitleOverlay(QWidget):
else flags & ~Qt.WindowType.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
# 창을 그냥 지나가서 잡을 수가 없다.
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.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:
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
cfg = self.overlay_cfg
@@ -248,53 +334,94 @@ class SubtitleOverlay(QWidget):
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)
if self._edit_mode:
self._draw_edit_chrome(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_edit_chrome(self, painter) -> None:
"""위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
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))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(90, 170, 255, 235))
for rect in self._corner_handles():
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()
for offset in (5, 10, 15):
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
return [
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
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
if not self._interactive 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())
corner = self._corner_at(event.position().toPoint())
if corner:
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:
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()),
)
if self._resize_corner:
self._resize_to(event.globalPosition().toPoint())
elif self._drag_from is not None:
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
if self._drag_from is None and self._resize_from is None:
if self._drag_from is None and not self._resize_corner:
return
self._drag_from = None
self._resize_from = None
self._resize_corner = ""
cfg = self.overlay_cfg
g = self.geometry()
@@ -309,10 +436,13 @@ class SubtitleOverlay(QWidget):
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked:
if not self._interactive:
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))
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()
def contextMenuEvent(self, event) -> None: # noqa: N802
@@ -343,7 +473,11 @@ class SubtitleOverlay(QWidget):
def _set_click_through(self, value: bool) -> None:
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()
def _set_always_on_top(self, value: bool) -> None:

View File

@@ -14,7 +14,12 @@ from PySide6.QtWidgets import (
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 ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine
@@ -65,12 +70,34 @@ class HomePage(QWidget):
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
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.clicked.connect(self.refresh_sources)
layout.addWidget(refresh)
card.add(row)
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:
card = Card()
row = QWidget()
@@ -145,29 +172,49 @@ class HomePage(QWidget):
# --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None:
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.clear()
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)
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.source_combo.blockSignals(False)
self._on_source_changed()
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
# 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다.
if mode == SOURCE_MODE_INPUT:
self.mode_button.setText("프로그램 소리에서 고르기")
self.mode_button.setToolTip("게임 등 프로그램과 출력 장치 목록으로 돌아갑니다.")
msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
"마이크 등 입력 장치로 듣습니다. 통화 상대 목소리나 내가 말하는 것을 "
"번역할 때 쓰세요."
if caps["input"]
else "이 환경에서는 입력 장치를 열 수 없습니다. Windows에서 실행하세요."
)
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)
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:
self.source_combo.setCurrentIndex(i)
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)
return
@@ -190,7 +241,7 @@ class HomePage(QWidget):
cfg.target_pid = src.pid
cfg.target_process_name = src.label
else:
cfg.backend = "device"
cfg.backend = src.kind # "device" 또는 "input"
cfg.device_index = src.device_index
cfg.target_pid = 0
self.config_changed.emit()
@@ -226,7 +277,12 @@ class HomePage(QWidget):
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):
for widget in (
self.source_combo,
self.mode_button,
self.source_lang,
self.target_lang,
):
widget.setEnabled(not running)
self.speech_combo.setEnabled(
not running and self.config.models.target_lang == "ko"

View File

@@ -17,8 +17,8 @@ from PySide6.QtWidgets import (
)
from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers
from ...models.manager import GpuInfo, tier_availability
from ...models import Tier, detect_gpu, get_tier, ordered_tiers
from ...models.manager import GpuInfo, recommended_tier, tier_availability
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
@@ -26,7 +26,13 @@ from ..widgets import Card, PageHeader
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)
self.tier = tier
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.setStyleSheet("font-size: 16px; font-weight: 700;")
head.addWidget(name)
if tier.recommended:
head.addWidget(_badge("추천", p.accent))
# '추천'은 표에 박힌 값이 아니라 이 PC 기준으로 계산해서 붙인다.
# GPU 가 없는데 10GB 짜리에 추천 배지가 달려 있으면 안내가 아니라 함정이다.
if recommended:
head.addWidget(_badge("CPU 추천" if not gpu.available else "추천", p.accent))
if tier.best_after_finetune:
head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
@@ -106,6 +114,8 @@ class ModelsPage(QWidget):
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
self._gpu = detect_gpu()
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.setContentsMargins(0, 0, 0, 0)
@@ -123,7 +133,7 @@ class ModelsPage(QWidget):
self._group = QButtonGroup(self)
self._group.setExclusive(True)
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)
card.radio.toggled.connect(
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.addItem("GPU (CUDA)", "cuda")
self.device_combo.addItem("CPU", "cpu")
# GPU 가 없으면 콤보를 비활성화만 하고 "GPU (CUDA)" 를 그대로 띄워두면
# 실제로는 CPU 로 도는데 화면은 GPU 라고 말하는 상태가 된다. 맞춰둔다.
if not gpu.available:
self.config.models.device = "cpu"
self.device_combo.setCurrentIndex(
max(0, self.device_combo.findData(self.config.models.device))
)
@@ -171,8 +185,42 @@ class ModelsPage(QWidget):
layout.addWidget(self.device_combo)
card.add(row)
if not gpu.available:
card.add(self._build_cpu_advice_row())
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:
card = Card(
"추가학습 어댑터 (선택)",

View File

@@ -61,6 +61,8 @@ class SubtitlePage(QWidget):
overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
#: '자막 위치 조정' 버튼 — 오버레이를 조정 가능한 상태로 열고 닫는다
edit_mode_requested = Signal(bool)
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
@@ -88,7 +90,7 @@ class SubtitlePage(QWidget):
card = Card(
"표시 위치",
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
"칸에 없는 자리에 두고 싶으면 아래 '자막 위치 조정'으로 직접 옮기세요.",
)
# --- on/off + 단축키 안내 ---
@@ -159,9 +161,55 @@ class SubtitlePage(QWidget):
margin_layout.addStretch(1)
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()
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:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
@@ -238,6 +286,17 @@ class SubtitlePage(QWidget):
layout.addWidget(self.opacity_value)
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()
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
self.align_combo.addItem(label, value)
@@ -345,6 +404,7 @@ class SubtitlePage(QWidget):
st.outline_width = self.outline_spin.value()
st.background_color = self.bg_color.color
st.background_opacity = self.opacity_slider.value()
st.padding = self.padding_spin.value()
st.align = self.align_combo.currentData()
st.max_lines = self.lines_spin.value()
st.fade_out_ms = self.fade_spin.value()

View 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

149
tests/test_subtitle_box.py Normal file
View 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()

View File

@@ -49,3 +49,21 @@ def test_unknown_key_falls_back_to_default():
def test_default_tier_exists():
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