diff --git a/README.md b/README.md index aa08527..fadf48e 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,14 @@ ## 주요 기능 - **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다 +- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다 - **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고) - **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다 +- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다 - **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다 - **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐) -- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지 -- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수 +- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다 +- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다) - **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다 - **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다 - **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고) diff --git a/src/livesub/audio/__init__.py b/src/livesub/audio/__init__.py index 4dc70c7..fca90f1 100644 --- a/src/livesub/audio/__init__.py +++ b/src/livesub/audio/__init__.py @@ -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(): diff --git a/src/livesub/audio/base.py b/src/livesub/audio/base.py index 8150811..b1d3f04 100644 --- a/src/livesub/audio/base.py +++ b/src/livesub/audio/base.py @@ -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): diff --git a/src/livesub/audio/input_device.py b/src/livesub/audio/input_device.py new file mode 100644 index 0000000..ac15375 --- /dev/null +++ b/src/livesub/audio/input_device.py @@ -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() diff --git a/src/livesub/config.py b/src/livesub/config.py index 50ad19f..5d2b83f 100644 --- a/src/livesub/config.py +++ b/src/livesub/config.py @@ -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 = 기본 출력 장치 루프백 diff --git a/src/livesub/models/__init__.py b/src/livesub/models/__init__.py index 5ee44e9..adb7846 100644 --- a/src/livesub/models/__init__.py +++ b/src/livesub/models/__init__.py @@ -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", ] diff --git a/src/livesub/models/manager.py b/src/livesub/models/manager.py index c74aa91..b4eed37 100644 --- a/src/livesub/models/manager.py +++ b/src/livesub/models/manager.py @@ -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) diff --git a/src/livesub/ui/main_window.py b/src/livesub/ui/main_window.py index 35b6ec9..ba6dd97 100644 --- a/src/livesub/ui/main_window.py +++ b/src/livesub/ui/main_window.py @@ -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() diff --git a/src/livesub/ui/overlay.py b/src/livesub/ui/overlay.py index cf946df..f5fca79 100644 --- a/src/livesub/ui/overlay.py +++ b/src/livesub/ui/overlay.py @@ -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: diff --git a/src/livesub/ui/pages/home.py b/src/livesub/ui/pages/home.py index c8676bc..20689c3 100644 --- a/src/livesub/ui/pages/home.py +++ b/src/livesub/ui/pages/home.py @@ -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" diff --git a/src/livesub/ui/pages/models_page.py b/src/livesub/ui/pages/models_page.py index 5af9efd..5787b73 100644 --- a/src/livesub/ui/pages/models_page.py +++ b/src/livesub/ui/pages/models_page.py @@ -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( "추가학습 어댑터 (선택)", diff --git a/src/livesub/ui/pages/subtitle_page.py b/src/livesub/ui/pages/subtitle_page.py index 0ac0e03..aa678c8 100644 --- a/src/livesub/ui/pages/subtitle_page.py +++ b/src/livesub/ui/pages/subtitle_page.py @@ -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() diff --git a/tests/test_audio_sources.py b/tests/test_audio_sources.py new file mode 100644 index 0000000..f267cc0 --- /dev/null +++ b/tests/test_audio_sources.py @@ -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 diff --git a/tests/test_subtitle_box.py b/tests/test_subtitle_box.py new file mode 100644 index 0000000..5a6d2cc --- /dev/null +++ b/tests/test_subtitle_box.py @@ -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() diff --git a/tests/test_tiers.py b/tests/test_tiers.py index 73962d6..a90e809 100644 --- a/tests/test_tiers.py +++ b/tests/test_tiers.py @@ -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