feat: Hearo 1차 구현 — 프로그램 소리 실시간 번역 자막

프로그램별 오디오를 캡처해 로컬 GPU에서 음성인식→번역하고 화면 위
자막으로 보여주는 데스크톱 앱. 한/영/일/중 4개 언어.

구성
- audio: WASAPI 프로그램별 캡처(C++ 보조 프로그램) + 장치 루프백 폴백,
  적응형 VAD 발화 분할
- models: 속도~품질 5단계 티어, faster-whisper + CTranslate2/LLM 2백엔드,
  용어집(플레이스홀더 보호 + 프롬프트 주입)
- core: Qt 비의존 파이프라인 엔진 (캡처/분할/추론 3스레드, 큐 연결)
- ui: 사이드바 5화면 + 무테두리 항상위 자막 오버레이, 자체 다크 테마

모델 선정 근거는 docs/MODELS.md, 추가학습 가능 여부와 방법은
docs/FINETUNING.md 참고.

검증: pytest 39개 통과 (GPU·오디오 장치 없이 실행), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-21 10:47:11 +09:00
parent c24e5f938d
commit 1a87ec6677
49 changed files with 4975 additions and 1 deletions

6
src/hearo/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Hearo — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구."""
from .constants import APP_NAME, APP_SLOGAN, APP_VERSION
__all__ = ["APP_NAME", "APP_SLOGAN", "APP_VERSION", "__version__"]
__version__ = APP_VERSION

4
src/hearo/__main__.py Normal file
View File

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

53
src/hearo/app.py Normal file
View File

@@ -0,0 +1,53 @@
"""애플리케이션 진입점."""
from __future__ import annotations
import contextlib
import logging
import sys
from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False) -> None:
log_file = user_data_dir() / "hearo.log"
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
with contextlib.suppress(OSError):
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
handlers=handlers,
force=True,
)
# 모델 라이브러리들이 INFO로 너무 시끄럽다.
for noisy in ("urllib3", "filelock", "huggingface_hub", "transformers"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
verbose = "--verbose" in argv or "-v" in argv
setup_logging(verbose)
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from .ui import MainWindow
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
app = QApplication(argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(ORG_NAME)
app.setQuitOnLastWindowClosed(True)
config = AppConfig.load()
window = MainWindow(config)
if not config.start_minimized:
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,72 @@
"""오디오 캡처 레이어."""
from __future__ import annotations
import logging
from ..config import AudioConfig
from .base import AudioSource, CaptureBackend, CaptureError
from .file_source import FileCapture
from .process_loopback import ProcessLoopbackCapture
from .segmenter import Segment, Segmenter, SegmenterConfig
from .wasapi_loopback import WasapiLoopbackCapture
log = logging.getLogger(__name__)
__all__ = [
"AudioSource",
"CaptureBackend",
"CaptureError",
"FileCapture",
"ProcessLoopbackCapture",
"Segment",
"Segmenter",
"SegmenterConfig",
"WasapiLoopbackCapture",
"capture_capabilities",
"create_capture",
"list_sources",
]
def capture_capabilities() -> dict[str, bool]:
"""현재 환경에서 어떤 캡처가 가능한지."""
return {
"process": ProcessLoopbackCapture.available(),
"device": WasapiLoopbackCapture.available(),
"file": True,
}
def list_sources() -> list[AudioSource]:
"""프로그램 + 출력 장치를 한 목록으로."""
sources: list[AudioSource] = []
if ProcessLoopbackCapture.available():
sources.extend(ProcessLoopbackCapture.list_sources())
sources.extend(WasapiLoopbackCapture.list_sources())
return sources
def create_capture(config: AudioConfig) -> CaptureBackend:
"""설정에 맞는 캡처 백엔드를 만든다. 불가능하면 장치 루프백으로 폴백."""
backend = config.backend
if backend == "file":
return FileCapture(config.target_process_name)
want_process = backend in ("process", "auto") and config.target_pid > 0
if want_process:
if ProcessLoopbackCapture.available():
return ProcessLoopbackCapture(config.target_pid)
if backend == "process":
raise CaptureError(
"프로그램별 캡처를 쓰려면 hearo_capture.exe 가 필요합니다. "
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
)
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")
if not WasapiLoopbackCapture.available():
raise CaptureError(
"이 환경에서는 오디오 캡처를 할 수 없습니다. "
"Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
)
return WasapiLoopbackCapture(config.device_index)

146
src/hearo/audio/base.py Normal file
View File

@@ -0,0 +1,146 @@
"""오디오 캡처 백엔드 공통 인터페이스.
모든 백엔드는 16kHz / mono / float32(-1.0~1.0) 프레임을 큐로 흘려보낸다.
리샘플링과 다운믹스는 각 백엔드가 책임진다.
"""
from __future__ import annotations
import abc
import queue
import threading
from dataclasses import dataclass
import numpy as np
from ..constants import SAMPLE_RATE
@dataclass(frozen=True)
class AudioSource:
"""UI에 노출되는 캡처 대상 한 줄."""
kind: str # "process" | "device"
identifier: str # pid 문자열 또는 장치 인덱스
label: str
detail: str = ""
icon_path: str = ""
@property
def pid(self) -> int:
return int(self.identifier) if self.kind == "process" else 0
@property
def device_index(self) -> int:
return int(self.identifier) if self.kind == "device" else -1
class CaptureError(RuntimeError):
"""캡처를 시작할 수 없을 때."""
class CaptureBackend(abc.ABC):
"""오디오 캡처 백엔드 베이스."""
name = "base"
#: 특정 프로그램 소리만 분리해서 받을 수 있는가
per_process = False
def __init__(self, max_queue_frames: int = 400) -> None:
self._queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=max_queue_frames)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._error: Exception | None = None
# --- 하위 클래스가 구현 -------------------------------------------
@abc.abstractmethod
def _run(self) -> None:
"""블로킹 캡처 루프. self._stop 이 set 될 때까지 _emit() 호출."""
@staticmethod
@abc.abstractmethod
def available() -> bool:
"""현재 환경에서 이 백엔드를 쓸 수 있는가."""
@staticmethod
@abc.abstractmethod
def list_sources() -> list[AudioSource]:
"""선택 가능한 캡처 대상 목록."""
# --- 공통 동작 -----------------------------------------------------
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._error = None
self._thread = threading.Thread(
target=self._thread_main, name=f"capture-{self.name}", daemon=True
)
self._thread.start()
def _thread_main(self) -> None:
try:
self._run()
except Exception as exc: # noqa: BLE001 - 워커 스레드 경계
self._error = exc
def stop(self, timeout: float = 2.0) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=timeout)
self._thread = None
@property
def running(self) -> bool:
return bool(self._thread and self._thread.is_alive())
@property
def error(self) -> Exception | None:
return self._error
def _emit(self, frame: np.ndarray) -> None:
"""캡처 프레임 투입. 큐가 가득 차면 가장 오래된 프레임을 버린다.
실시간 자막에서는 밀린 오디오보다 최신 오디오가 항상 더 가치 있다.
"""
try:
self._queue.put_nowait(frame)
except queue.Full:
try:
self._queue.get_nowait()
self._queue.put_nowait(frame)
except (queue.Empty, queue.Full):
pass
def read(self, timeout: float = 0.5) -> np.ndarray | None:
try:
return self._queue.get(timeout=timeout)
except queue.Empty:
return None
def drain(self) -> None:
while not self._queue.empty():
try:
self._queue.get_nowait()
except queue.Empty:
break
def to_mono_16k(data: np.ndarray, channels: int, src_rate: int) -> np.ndarray:
"""인터리브된 float32 PCM을 16kHz 모노로 변환."""
if data.size == 0:
return data.astype(np.float32, copy=False)
audio = data.astype(np.float32, copy=False)
if channels > 1:
usable = (audio.size // channels) * channels
audio = audio[:usable].reshape(-1, channels).mean(axis=1)
if src_rate != SAMPLE_RATE and audio.size:
# 선형 보간 리샘플. 음성인식 입력으로는 충분한 품질이며 의존성이 없다.
duration = audio.size / src_rate
target_len = max(1, int(duration * SAMPLE_RATE))
audio = np.interp(
np.linspace(0.0, audio.size - 1, target_len, dtype=np.float64),
np.arange(audio.size, dtype=np.float64),
audio,
).astype(np.float32)
return audio

View File

@@ -0,0 +1,66 @@
"""파일/합성 오디오 캡처 백엔드.
실제 오디오 장치 없이 파이프라인 전체를 돌려볼 수 있게 해준다.
개발·테스트·데모용이며 어느 OS에서나 동작한다.
"""
from __future__ import annotations
import time
import wave
from pathlib import Path
import numpy as np
from ..constants import FRAME_SAMPLES, SAMPLE_RATE
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
class FileCapture(CaptureBackend):
"""WAV 파일을 실시간 속도로 재생하듯 흘려보낸다."""
name = "file"
per_process = False
def __init__(self, path: str | Path, loop: bool = False, realtime: bool = True) -> None:
super().__init__()
self.path = Path(path)
self.loop = loop
self.realtime = realtime
@staticmethod
def available() -> bool:
return True
@staticmethod
def list_sources() -> list[AudioSource]:
return []
def _read_wav(self) -> np.ndarray:
if not self.path.is_file():
raise CaptureError(f"오디오 파일을 찾을 수 없습니다: {self.path}")
with wave.open(str(self.path), "rb") as wf:
channels = wf.getnchannels()
rate = wf.getframerate()
width = wf.getsampwidth()
raw = wf.readframes(wf.getnframes())
if width == 2:
data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
elif width == 4:
data = np.frombuffer(raw, dtype=np.float32)
else:
raise CaptureError(f"지원하지 않는 샘플 폭입니다: {width * 8}bit")
return to_mono_16k(data, channels, rate)
def _run(self) -> None:
audio = self._read_wav()
chunk = FRAME_SAMPLES * 5 # 100ms
while not self._stop.is_set():
for start in range(0, audio.size, chunk):
if self._stop.is_set():
return
self._emit(audio[start : start + chunk].copy())
if self.realtime:
time.sleep(chunk / SAMPLE_RATE)
if not self.loop:
return

View File

@@ -0,0 +1,168 @@
"""프로그램별 오디오 캡처 (Windows WASAPI process loopback).
`native/process_loopback` 에서 빌드한 `hearo_capture.exe` 를 자식 프로세스로 띄우고
stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다.
보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는
장치 루프백으로 자동 폴백한다.
"""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
import numpy as np
from ..constants import FRAME_SAMPLES
from .base import AudioSource, CaptureBackend, CaptureError
log = logging.getLogger(__name__)
HELPER_NAME = "hearo_capture.exe"
READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량
def helper_path() -> Path | None:
"""보조 프로그램 위치. 패키지 내부 → 저장소 빌드 폴더 순으로 찾는다."""
candidates = [
Path(__file__).resolve().parent.parent / "resources" / "bin" / HELPER_NAME,
Path(__file__).resolve().parents[3]
/ "native"
/ "process_loopback"
/ "build"
/ "Release"
/ HELPER_NAME,
]
for path in candidates:
if path.is_file():
return path
return None
class ProcessLoopbackCapture(CaptureBackend):
name = "process"
per_process = True
def __init__(self, pid: int, exclude: bool = False) -> None:
super().__init__()
self.pid = pid
self.exclude = exclude
self._proc: subprocess.Popen[bytes] | None = None
@staticmethod
def available() -> bool:
return sys.platform == "win32" and helper_path() is not None
@staticmethod
def list_sources() -> list[AudioSource]:
"""현재 소리를 내고 있는(또는 낼 수 있는) 프로세스 목록."""
if sys.platform != "win32":
return []
try:
from pycaw.pycaw import AudioUtilities
except ImportError:
log.debug("pycaw 미설치 — 프로세스 목록을 만들 수 없습니다.")
return []
seen: dict[int, AudioSource] = {}
for session in AudioUtilities.GetAllSessions():
proc = session.Process
if proc is None or proc.pid in seen:
continue
try:
name = proc.name()
except Exception: # noqa: BLE001 - 종료된 프로세스
continue
title = _window_title(proc.pid) or name
seen[proc.pid] = AudioSource(
kind="process",
identifier=str(proc.pid),
label=title,
detail=f"{name} (PID {proc.pid})",
)
return sorted(seen.values(), key=lambda s: s.label.lower())
def _run(self) -> None: # pragma: no cover - Windows 전용
exe = helper_path()
if exe is None:
raise CaptureError(
f"{HELPER_NAME} 를 찾지 못했습니다. "
"native/process_loopback/build.ps1 로 먼저 빌드하세요."
)
cmd = [str(exe), "--pid", str(self.pid)]
if self.exclude:
cmd.append("--exclude")
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
assert self._proc.stdout is not None
try:
while not self._stop.is_set():
raw = self._proc.stdout.read(READ_BYTES)
if not raw:
code = self._proc.poll()
err = b""
if self._proc.stderr is not None:
err = self._proc.stderr.read() or b""
raise CaptureError(
f"캡처 보조 프로그램이 종료되었습니다(코드 {code}): "
f"{err.decode('utf-8', 'replace').strip()}"
)
# 4바이트 정렬이 깨지면 float32 해석이 밀린다.
usable = len(raw) - (len(raw) % 4)
self._emit(np.frombuffer(raw[:usable], dtype=np.float32).copy())
finally:
self._terminate()
def _terminate(self) -> None:
proc, self._proc = self._proc, None
if proc is None:
return
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
for pipe in (proc.stdout, proc.stderr):
if pipe is not None:
pipe.close()
def _window_title(pid: int) -> str:
"""해당 PID의 최상위 창 제목. 실패하면 빈 문자열."""
if sys.platform != "win32":
return ""
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
result: list[str] = []
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _lparam):
owner = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
if owner.value != pid or not user32.IsWindowVisible(hwnd):
return True
length = user32.GetWindowTextLengthW(hwnd)
if length <= 0:
return True
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
result.append(buf.value)
return False
user32.EnumWindows(callback, 0)
return result[0] if result else ""
except Exception: # noqa: BLE001 - 창 제목은 부가 정보일 뿐
return ""

View File

@@ -0,0 +1,188 @@
"""발화 구간 분할기.
캡처된 연속 오디오를 "한 문장" 단위로 잘라 음성인식에 넘긴다.
외부 의존성 없이 동작하도록 적응형 노이즈 플로어 기반 에너지 VAD를 쓴다.
webrtcvad 가 설치돼 있으면 더 정확한 판정을 위해 함께 사용한다.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
import numpy as np
from ..constants import FRAME_MS, FRAME_SAMPLES, SAMPLE_RATE
try: # pragma: no cover - 선택 의존성
import webrtcvad
except ImportError: # pragma: no cover
webrtcvad = None # type: ignore[assignment]
@dataclass
class Segment:
"""음성인식에 넘길 오디오 한 덩어리."""
audio: np.ndarray
started_at: float
ended_at: float
is_final: bool = True
@property
def duration_s(self) -> float:
return self.audio.size / SAMPLE_RATE
@dataclass
class SegmenterConfig:
silence_ms: int = 600
min_segment_ms: int = 400
max_segment_ms: int = 12_000
partial_interval_ms: int = 900
#: 노이즈 플로어 대비 몇 배 이상이면 발화로 볼지
speech_ratio: float = 3.0
#: 절대 무음 판정 하한 (RMS)
absolute_floor: float = 2e-4
vad_aggressiveness: int = 2
#: 발화 시작 앞쪽에 붙일 여유분 (앞 음절 잘림 방지)
lead_in_ms: int = 240
class Segmenter:
"""프레임을 받아 발화 구간을 뱉는 상태 기계.
`push()` 는 확정 구간(is_final=True) 또는 중간 결과(is_final=False)를
돌려주거나, 아직 내보낼 게 없으면 None 을 돌려준다.
"""
def __init__(self, config: SegmenterConfig | None = None) -> None:
self.config = config or SegmenterConfig()
self._vad = None
if webrtcvad is not None:
self._vad = webrtcvad.Vad(max(0, min(3, self.config.vad_aggressiveness)))
self._buffer: list[np.ndarray] = []
self._lead_in: list[np.ndarray] = []
self._pending = np.zeros(0, dtype=np.float32)
self._noise_floor = 1e-3
self._silence_ms = 0
self._speech_ms = 0
self._in_speech = False
self._started_at = 0.0
self._last_partial_ms = 0
self._lead_in_frames = max(1, self.config.lead_in_ms // FRAME_MS)
# --- 입력 ----------------------------------------------------------
def push(self, chunk: np.ndarray) -> list[Segment]:
"""임의 길이의 오디오를 넣고 완성된 구간 목록을 받는다."""
out: list[Segment] = []
if chunk.size:
self._pending = (
chunk.astype(np.float32, copy=False)
if self._pending.size == 0
else np.concatenate((self._pending, chunk))
)
while self._pending.size >= FRAME_SAMPLES:
frame = self._pending[:FRAME_SAMPLES]
self._pending = self._pending[FRAME_SAMPLES:]
segment = self._push_frame(frame)
if segment is not None:
out.append(segment)
return out
def flush(self) -> Segment | None:
"""남은 버퍼를 강제로 확정 구간으로 만든다 (캡처 종료 시)."""
if not self._in_speech:
return None
return self._finish()
def reset(self) -> None:
"""발화 상태만 초기화한다.
`_pending` 은 건드리지 않는다. 아직 프레임으로 쪼개지 못한 '입력' 이라
여기서 버리면 한 구간을 확정한 직후의 오디오가 통째로 사라진다.
"""
self._buffer.clear()
self._lead_in.clear()
self._silence_ms = 0
self._speech_ms = 0
self._in_speech = False
self._last_partial_ms = 0
# --- 내부 ----------------------------------------------------------
def _push_frame(self, frame: np.ndarray) -> Segment | None:
speech = self._is_speech(frame)
cfg = self.config
if not self._in_speech:
self._lead_in.append(frame)
if len(self._lead_in) > self._lead_in_frames:
self._lead_in.pop(0)
if speech:
self._in_speech = True
self._started_at = time.monotonic()
self._buffer = list(self._lead_in)
self._lead_in = []
self._silence_ms = 0
self._speech_ms = FRAME_MS
self._last_partial_ms = 0
return None
self._buffer.append(frame)
self._speech_ms += FRAME_MS
self._silence_ms = 0 if speech else self._silence_ms + FRAME_MS
buffered_ms = len(self._buffer) * FRAME_MS
if self._silence_ms >= cfg.silence_ms:
if buffered_ms - self._silence_ms < cfg.min_segment_ms:
self.reset() # 잡음 한 번 튄 것 — 버린다
return None
return self._finish()
if buffered_ms >= cfg.max_segment_ms:
return self._finish()
if cfg.partial_interval_ms > 0:
since = buffered_ms - self._last_partial_ms
if since >= cfg.partial_interval_ms and buffered_ms >= cfg.min_segment_ms:
self._last_partial_ms = buffered_ms
return Segment(
audio=np.concatenate(self._buffer),
started_at=self._started_at,
ended_at=time.monotonic(),
is_final=False,
)
return None
def _finish(self) -> Segment:
audio = np.concatenate(self._buffer) if self._buffer else np.zeros(0, np.float32)
segment = Segment(
audio=audio,
started_at=self._started_at,
ended_at=time.monotonic(),
is_final=True,
)
self.reset()
return segment
def _is_speech(self, frame: np.ndarray) -> bool:
rms = float(np.sqrt(np.mean(np.square(frame), dtype=np.float64)))
cfg = self.config
if rms < cfg.absolute_floor:
self._noise_floor = min(self._noise_floor, max(rms, 1e-6))
return False
energetic = rms > max(self._noise_floor * cfg.speech_ratio, cfg.absolute_floor)
if not energetic:
# 무음 구간에서만 노이즈 플로어를 천천히 따라가게 한다.
self._noise_floor = 0.95 * self._noise_floor + 0.05 * rms
if self._vad is None:
return energetic
try:
pcm16 = (np.clip(frame, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
return energetic and self._vad.is_speech(pcm16, SAMPLE_RATE)
except Exception: # noqa: BLE001 - webrtcvad 는 프레임 길이에 민감
return energetic

View File

@@ -0,0 +1,95 @@
"""WASAPI 장치 루프백 캡처 (Windows).
출력 장치 전체의 소리를 받는다. 프로그램별 분리는 안 되지만 의존성이
`PyAudioWPatch` 하나뿐이라 어디서나 바로 동작하는 기본 백엔드다.
"""
from __future__ import annotations
import contextlib
import logging
import numpy as np
from ..constants import FRAME_SAMPLES
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
log = logging.getLogger(__name__)
try: # pragma: no cover - 플랫폼 의존
import pyaudiowpatch as pyaudio
except ImportError: # pragma: no cover
pyaudio = None # type: ignore[assignment]
class WasapiLoopbackCapture(CaptureBackend):
name = "device"
per_process = False
def __init__(self, device_index: int = -1) -> None:
super().__init__()
self.device_index = device_index
@staticmethod
def available() -> bool:
return pyaudio is not None
@staticmethod
def list_sources() -> list[AudioSource]:
if pyaudio is None:
return []
sources: list[AudioSource] = []
pa = pyaudio.PyAudio()
try:
default_index = -1
with contextlib.suppress(OSError, LookupError):
default_index = pa.get_default_wasapi_loopback()["index"]
for info in pa.get_loopback_device_info_generator():
idx = int(info["index"])
sources.append(
AudioSource(
kind="device",
identifier=str(idx),
label=str(info["name"]).replace(" [Loopback]", ""),
detail="기본 출력 장치" if idx == default_index else "출력 장치",
)
)
finally:
pa.terminate()
return sources
def _resolve_device(self, pa) -> dict:
if self.device_index >= 0:
return pa.get_device_info_by_index(self.device_index)
return pa.get_default_wasapi_loopback()
def _run(self) -> None: # pragma: no cover - 실제 오디오 장치 필요
if pyaudio is None:
raise CaptureError("PyAudioWPatch가 설치되지 않았습니다.")
pa = pyaudio.PyAudio()
stream = None
try:
info = self._resolve_device(pa)
if info is None:
raise CaptureError("루프백 장치를 찾지 못했습니다.")
rate = int(info["defaultSampleRate"])
channels = int(info["maxInputChannels"]) or 2
chunk = max(FRAME_SAMPLES, int(rate * 0.02))
stream = pa.open(
format=pyaudio.paFloat32,
channels=channels,
rate=rate,
input=True,
input_device_index=int(info["index"]),
frames_per_buffer=chunk,
)
log.info("장치 루프백 캡처 시작: %s (%dHz, %dch)", info["name"], rate, channels)
while not self._stop.is_set():
raw = stream.read(chunk, exception_on_overflow=False)
data = np.frombuffer(raw, dtype=np.float32)
self._emit(to_mono_16k(data, channels, rate))
finally:
if stream is not None:
stream.stop_stream()
stream.close()
pa.terminate()

140
src/hearo/config.py Normal file
View File

@@ -0,0 +1,140 @@
"""설정 모델과 JSON 영속화."""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from pathlib import Path
from typing import Any
from .constants import DEFAULT_TIER, user_data_dir
log = logging.getLogger(__name__)
CONFIG_PATH = user_data_dir() / "config.json"
@dataclass
class SubtitleStyle:
"""자막 텍스트 표현 설정."""
font_family: str = "Pretendard"
font_size: int = 34
bold: bool = True
text_color: str = "#FFFFFF"
source_text_color: str = "#9AA4B2"
outline_color: str = "#000000"
outline_width: int = 3
background_color: str = "#000000"
background_opacity: int = 55 # 0-100
line_spacing: int = 6
max_lines: int = 2
show_source: bool = False # 원문 동시 표시
align: str = "center" # left | center | right
fade_out_ms: int = 4000 # 0이면 계속 유지
@dataclass
class OverlayConfig:
"""자막 창(오버레이) 위치/동작."""
x: int = -1 # -1 = 화면 하단 중앙 자동 배치
y: int = -1
width: int = 1100
height: int = 200
always_on_top: bool = True
click_through: bool = False
locked: bool = False
visible: bool = True
@dataclass
class AudioConfig:
backend: str = "auto" # auto | process | device | file
target_pid: int = 0
target_process_name: str = ""
device_index: int = -1 # -1 = 기본 출력 장치 루프백
input_gain: float = 1.0
vad_aggressiveness: int = 2 # 0-3
silence_ms: int = 600 # 이 시간만큼 조용하면 한 문장 종료
min_segment_ms: int = 400
max_segment_ms: int = 12_000
partial_interval_ms: int = 900 # 중간 결과 갱신 주기, 0이면 끔
@dataclass
class ModelConfig:
tier: str = DEFAULT_TIER
device: str = "cuda" # cuda | cpu
device_index: int = 0
source_lang: str = "auto" # auto 또는 언어코드
target_lang: str = "ko"
preload_on_start: bool = True
lora_adapter_path: str = "" # 추가학습 어댑터 (비어있으면 미사용)
@dataclass
class GlossaryConfig:
enabled: bool = True
path: str = "" # 비어있으면 user_data_dir()/glossary.json
case_sensitive: bool = False
@dataclass
class AppConfig:
audio: AudioConfig = field(default_factory=AudioConfig)
models: ModelConfig = field(default_factory=ModelConfig)
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
overlay: OverlayConfig = field(default_factory=OverlayConfig)
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
theme: str = "dark" # dark | light
start_minimized: bool = False
log_transcripts: bool = True
# --- 영속화 ---------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def save(self, path: Path | None = None) -> Path:
target = path or CONFIG_PATH
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".json.tmp")
tmp.write_text(
json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
)
tmp.replace(target)
return target
@classmethod
def load(cls, path: Path | None = None) -> AppConfig:
target = path or CONFIG_PATH
if not target.exists():
return cls()
try:
raw = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("설정 파일을 읽지 못해 기본값을 사용합니다: %s", exc)
return cls()
return _from_dict(cls, raw)
def _from_dict(cls: type, raw: Any) -> Any:
"""알 수 없는 키는 버리고 누락된 키는 기본값으로 채우는 관대한 역직렬화.
기본값 인스턴스를 먼저 만든 뒤 알고 있는 필드만 덮어쓴다. 덕분에
설정 스키마가 바뀌어도 사용자의 기존 config.json이 깨지지 않는다.
"""
instance = cls()
if not isinstance(raw, dict):
return instance
known = {f.name for f in fields(cls)}
for key, value in raw.items():
if key not in known:
continue
current = getattr(instance, key)
if is_dataclass(current) and not isinstance(current, type):
setattr(instance, key, _from_dict(type(current), value))
else:
setattr(instance, key, value)
return instance

52
src/hearo/constants.py Normal file
View File

@@ -0,0 +1,52 @@
"""전역 상수. 제품명 변경은 이 파일만 고치면 됩니다."""
from __future__ import annotations
from pathlib import Path
APP_NAME = "Hearo"
APP_NAME_KO = "히어로"
APP_SLOGAN = "듣고, 바로 이해하다"
APP_VERSION = "0.1.0"
ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개)
LANGUAGES: dict[str, dict[str, str]] = {
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "flag": "KO"},
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "flag": "EN"},
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "flag": "JA"},
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "flag": "ZH"},
}
LANGUAGE_CODES = tuple(LANGUAGES.keys())
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
DEFAULT_TIER = "balance"
# 오디오 파이프라인 규격
SAMPLE_RATE = 16_000
CHANNELS = 1
FRAME_MS = 20
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
def user_data_dir() -> Path:
"""설정/로그/모델 캐시를 두는 사용자 디렉터리."""
import os
import sys
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
path = base / APP_NAME
path.mkdir(parents=True, exist_ok=True)
return path
def models_dir() -> Path:
path = user_data_dir() / "models"
path.mkdir(parents=True, exist_ok=True)
return path

View File

@@ -0,0 +1,6 @@
"""파이프라인 코어."""
from .engine import TranslationEngine
from .events import EngineState, EngineStatus, TranslationLine
__all__ = ["EngineState", "EngineStatus", "TranslationEngine", "TranslationLine"]

259
src/hearo/core/engine.py Normal file
View File

@@ -0,0 +1,259 @@
"""실시간 번역 파이프라인 오케스트레이션.
캡처 스레드 → [오디오 큐] → 분할 스레드 → [구간 큐] → 인식/번역 워커 → 콜백
UI 프레임워크에 의존하지 않는다. Qt 쪽은 콜백을 시그널로 다시 던져준다.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
from collections.abc import Callable
import numpy as np
from ..audio import (
CaptureBackend,
CaptureError,
Segment,
Segmenter,
SegmenterConfig,
create_capture,
)
from ..config import AppConfig
from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager
from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__)
StatusCallback = Callable[[EngineStatus], None]
LineCallback = Callable[[TranslationLine], None]
class TranslationEngine:
def __init__(
self,
config: AppConfig,
on_line: LineCallback | None = None,
on_status: StatusCallback | None = None,
) -> None:
self.config = config
self._on_line = on_line
self._on_status = on_status
self.models = ModelManager(config.models)
self.glossary = self._load_glossary()
self._capture: CaptureBackend | None = None
self._segments: queue.Queue[Segment] = queue.Queue(maxsize=32)
self._threads: list[threading.Thread] = []
self._stop = threading.Event()
self._status = EngineStatus()
self._level = 0.0
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
self._last_final_text = ""
# --- 수명주기 -------------------------------------------------------
@property
def status(self) -> EngineStatus:
return self._status
@property
def running(self) -> bool:
return self._status.state in (EngineState.RUNNING, EngineState.LOADING)
def start(self) -> None:
if self.running:
return
self._stop.clear()
self._emit_status(EngineState.LOADING, "모델을 준비하고 있습니다…", 0.0)
thread = threading.Thread(target=self._bootstrap, name="engine-boot", daemon=True)
thread.start()
self._threads = [thread]
def stop(self) -> None:
if self._status.state is EngineState.IDLE:
return
self._emit_status(EngineState.STOPPING, "정지 중…")
self._stop.set()
if self._capture is not None:
self._capture.stop()
self._capture = None
for t in self._threads:
if t is not threading.current_thread():
t.join(timeout=3)
self._threads = []
self._drain_segments()
self._emit_status(EngineState.IDLE, "대기 중")
def shutdown(self) -> None:
self.stop()
self.models.unload()
def reload_glossary(self) -> None:
self.glossary = self._load_glossary()
# --- 내부 스레드 ----------------------------------------------------
def _bootstrap(self) -> None:
try:
if self.config.models.preload_on_start:
self.models.preload(
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
)
self._capture = create_capture(self.config.audio)
self._capture.start()
workers = [
threading.Thread(target=self._segment_loop, name="engine-segment", daemon=True),
threading.Thread(target=self._infer_loop, name="engine-infer", daemon=True),
]
for w in workers:
w.start()
self._threads.extend(workers)
kind = "프로그램별 캡처" if self._capture.per_process else "출력 장치 전체"
self._emit_status(EngineState.RUNNING, f"듣는 중 · {kind}", 1.0)
except (CaptureError, OSError, RuntimeError, ImportError) as exc:
log.exception("엔진 시작 실패")
self._emit_status(EngineState.ERROR, str(exc))
def _segment_loop(self) -> None:
cfg = self.config.audio
segmenter = Segmenter(
SegmenterConfig(
silence_ms=cfg.silence_ms,
min_segment_ms=cfg.min_segment_ms,
max_segment_ms=cfg.max_segment_ms,
partial_interval_ms=cfg.partial_interval_ms,
vad_aggressiveness=cfg.vad_aggressiveness,
)
)
while not self._stop.is_set():
capture = self._capture
if capture is None:
break
chunk = capture.read(timeout=0.3)
if chunk is None:
if capture.error is not None:
self._emit_status(EngineState.ERROR, str(capture.error))
return
continue
if cfg.input_gain != 1.0:
chunk = np.clip(chunk * cfg.input_gain, -1.0, 1.0)
self._level = float(np.sqrt(np.mean(np.square(chunk, dtype=np.float64))))
for segment in segmenter.push(chunk):
self._enqueue(segment)
leftover = segmenter.flush()
if leftover is not None:
self._enqueue(leftover)
def _enqueue(self, segment: Segment) -> None:
try:
self._segments.put_nowait(segment)
except queue.Full:
# 밀렸다면 중간 결과부터 버린다. 확정 문장은 지켜야 한다.
if not segment.is_final:
return
try:
self._segments.get_nowait()
self._segments.put_nowait(segment)
except (queue.Empty, queue.Full):
pass
def _infer_loop(self) -> None:
recognizer = self.models.recognizer()
translator = self.models.translator()
mcfg = self.config.models
while not self._stop.is_set():
try:
segment = self._segments.get(timeout=0.3)
except queue.Empty:
continue
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
if not segment.is_final and not self._segments.empty():
continue
try:
self._process(segment, recognizer, translator, mcfg)
except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다
log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG))
def _process(self, segment: Segment, recognizer, translator, mcfg) -> None:
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
t0 = time.perf_counter()
transcript = recognizer.transcribe(
segment.audio,
language=source_lang,
fast=not segment.is_final,
prompt=self._last_final_text if segment.is_final else "",
)
asr_ms = int((time.perf_counter() - t0) * 1000)
if not transcript.text:
return
detected = transcript.language if transcript.language in LANGUAGE_CODES else mcfg.target_lang
target = mcfg.target_lang
translated = ""
mt_ms = 0
if segment.is_final:
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
t1 = time.perf_counter()
translated = translator.translate(
transcript.text,
detected,
target,
self.glossary if self.config.glossary.enabled else None,
)
mt_ms = int((time.perf_counter() - t1) * 1000)
self._last_final_text = transcript.text
line = TranslationLine(
source_text=transcript.text,
translated_text=translated,
source_lang=detected,
target_lang=target,
is_final=segment.is_final,
latency_s=time.monotonic() - segment.ended_at,
asr_ms=asr_ms,
mt_ms=mt_ms,
)
if self._on_line:
self._on_line(line)
self._status.level = self._level
self._status.queue_depth = self._segments.qsize()
# --- 유틸 -----------------------------------------------------------
def _load_glossary(self) -> Glossary:
cfg = self.config.glossary
path = cfg.path or str(user_data_dir() / "glossary.json")
return Glossary.load(path, cfg.case_sensitive)
def _drain_segments(self) -> None:
while not self._segments.empty():
try:
self._segments.get_nowait()
except queue.Empty:
break
def _emit_status(self, state: EngineState, message: str, progress: float | None = None) -> None:
self._status = EngineStatus(
state=state,
message=message,
progress=self._status.progress if progress is None else progress,
level=self._level,
queue_depth=self._segments.qsize(),
)
if self._on_status:
self._on_status(self._status)

45
src/hearo/core/events.py Normal file
View File

@@ -0,0 +1,45 @@
"""엔진이 UI로 올려보내는 이벤트 타입."""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
class EngineState(str, Enum):
IDLE = "idle"
LOADING = "loading"
RUNNING = "running"
STOPPING = "stopping"
ERROR = "error"
@dataclass
class TranslationLine:
"""화면에 뿌릴 자막 한 줄."""
source_text: str
translated_text: str
source_lang: str
target_lang: str
is_final: bool = True
created_at: float = field(default_factory=time.time)
#: 발화가 끝난 시점부터 번역이 나오기까지 걸린 시간(초)
latency_s: float = 0.0
asr_ms: int = 0
mt_ms: int = 0
@property
def display(self) -> str:
return self.translated_text or self.source_text
@dataclass
class EngineStatus:
state: EngineState = EngineState.IDLE
message: str = ""
progress: float = 0.0
#: 최근 입력 오디오 레벨 (0.0 ~ 1.0), VU 미터용
level: float = 0.0
queue_depth: int = 0

6
src/hearo/ui/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Qt UI 레이어."""
from .main_window import MainWindow
from .overlay import SubtitleOverlay, SubtitlePreview
__all__ = ["MainWindow", "SubtitleOverlay", "SubtitlePreview"]

258
src/hearo/ui/main_window.py Normal file
View File

@@ -0,0 +1,258 @@
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
담당한다. 그래서 두 창이 서로 방해하지 않는다.
"""
from __future__ import annotations
import logging
from datetime import datetime
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from ..config import AppConfig
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
from .overlay import SubtitleOverlay
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
from .theme import SPACING, palette, stylesheet
log = logging.getLogger(__name__)
NAV = [
("홈", "실시간 번역"),
("모델", "품질 티어"),
("자막", "표시 설정"),
("용어집", "고유명사 고정"),
("설정", "세부 조정"),
]
class MainWindow(QMainWindow):
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
line_received = Signal(object)
status_received = Signal(object)
def __init__(self, config: AppConfig) -> None:
super().__init__()
self.config = config
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
self.resize(1060, 820)
self.engine = TranslationEngine(
config,
on_line=self.line_received.emit,
on_status=self.status_received.emit,
)
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
self._transcript_file = None
self._build_ui()
self._connect()
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
self._save_timer.timeout.connect(lambda: self.config.save())
# --- UI -------------------------------------------------------------
def _build_ui(self) -> None:
self.setStyleSheet(stylesheet(self.config.theme))
central = QWidget()
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._build_sidebar())
self.stack = QStackedWidget()
self.home_page = HomePage(self.config)
self.models_page = ModelsPage(self.config)
self.subtitle_page = SubtitlePage(self.config)
self.glossary_page = GlossaryPage(self.config)
self.settings_page = SettingsPage(self.config)
for page in (
self.home_page,
self.models_page,
self.subtitle_page,
self.glossary_page,
self.settings_page,
):
self.stack.addWidget(_scrollable(page))
layout.addWidget(self.stack, 1)
self.setCentralWidget(central)
def _build_sidebar(self) -> QFrame:
p = palette(self.config.theme)
bar = QFrame()
bar.setObjectName("Sidebar")
bar.setFixedWidth(212)
layout = QVBoxLayout(bar)
layout.setContentsMargins(14, 22, 14, 18)
layout.setSpacing(6)
brand = QLabel(APP_NAME)
brand.setStyleSheet(
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
)
layout.addWidget(brand)
tag = QLabel(APP_SLOGAN)
tag.setObjectName("Hint")
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
layout.addWidget(tag)
layout.addSpacing(SPACING + 8)
self._nav_group = QButtonGroup(self)
self._nav_group.setExclusive(True)
for index, (title, hint) in enumerate(NAV):
button = QPushButton(title)
button.setObjectName("NavButton")
button.setCheckable(True)
button.setToolTip(hint)
button.setCursor(Qt.CursorShape.PointingHandCursor)
self._nav_group.addButton(button, index)
layout.addWidget(button)
self._nav_group.button(0).setChecked(True)
self._nav_group.idClicked.connect(self._goto_page)
layout.addStretch(1)
version = QLabel(f"v{APP_VERSION}")
version.setObjectName("Hint")
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version)
return bar
def _connect(self) -> None:
self.line_received.connect(self._on_line)
self.status_received.connect(self._on_status)
self.home_page.start_requested.connect(self.start_engine)
self.home_page.stop_requested.connect(self.stop_engine)
self.home_page.config_changed.connect(self._schedule_save)
self.models_page.tier_changed.connect(self._on_tier_changed)
self.models_page.config_changed.connect(self._schedule_save)
self.subtitle_page.style_changed.connect(self._on_style_changed)
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
self.settings_page.config_changed.connect(self._schedule_save)
self.settings_page.restart_needed.connect(self._on_restart_needed)
self.overlay.geometry_changed.connect(lambda *_: self._schedule_save())
self.overlay.closed.connect(
lambda: self.subtitle_page.overlay_check.setChecked(False)
)
# --- 엔진 -----------------------------------------------------------
def start_engine(self) -> None:
if self.config.overlay.visible:
self.overlay.show()
self.config.save()
self.engine.start()
def stop_engine(self) -> None:
self.engine.stop()
self.overlay.clear()
self._close_transcript()
def _on_tier_changed(self, tier_key: str) -> None:
was_running = self.engine.running
if was_running:
self.engine.stop()
self.engine.models.switch_tier(tier_key)
if was_running:
self.engine.start()
def _on_restart_needed(self) -> None:
if self.engine.running:
self.engine.stop()
self.engine.start()
# --- 이벤트 ---------------------------------------------------------
def _on_line(self, line: TranslationLine) -> None:
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
self.home_page.on_line(line)
if line.is_final and self.config.log_transcripts:
self._write_transcript(line)
def _on_status(self, status: EngineStatus) -> None:
self.home_page.on_status(status)
if status.state is EngineState.ERROR:
QMessageBox.warning(self, "오류", status.message)
def _on_style_changed(self) -> None:
self.overlay.update()
self._schedule_save()
def _on_overlay_toggled(self, visible: bool) -> None:
self.config.overlay.visible = visible
self.overlay.setVisible(visible)
self._schedule_save()
def _on_glossary_changed(self) -> None:
self.engine.reload_glossary()
self._schedule_save()
def _goto_page(self, index: int) -> None:
self.stack.setCurrentIndex(index)
def _schedule_save(self) -> None:
self._save_timer.start()
# --- 기록 -----------------------------------------------------------
def _write_transcript(self, line: TranslationLine) -> None:
if self._transcript_file is None:
folder = user_data_dir() / "transcripts"
folder.mkdir(parents=True, exist_ok=True)
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
stamp = datetime.now().strftime("%H:%M:%S")
self._transcript_file.write(
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
)
self._transcript_file.flush()
def _close_transcript(self) -> None:
if self._transcript_file is not None:
self._transcript_file.close()
self._transcript_file = None
# --- 종료 -----------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.config.save()
self.engine.shutdown()
self._close_transcript()
self.overlay.close()
super().closeEvent(event)
def _scrollable(widget: QWidget) -> QScrollArea:
area = QScrollArea()
area.setWidgetResizable(True)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
holder = QWidget()
layout = QVBoxLayout(holder)
layout.setContentsMargins(26, 24, 26, 24)
layout.addWidget(widget)
area.setWidget(holder)
return area

346
src/hearo/ui/overlay.py Normal file
View File

@@ -0,0 +1,346 @@
"""자막 오버레이 창.
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
조작:
* 드래그 — 위치 이동
* 우하단 모서리 — 크기 조절
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
"""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QColor,
QFont,
QFontMetrics,
QPainter,
QPainterPath,
QPen,
)
from PySide6.QtWidgets import QApplication, QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle
GRIP = 18 # 우하단 리사이즈 핸들 크기
def paint_subtitle(
painter: QPainter,
rect: QRect,
st: SubtitleStyle,
translated: str,
source: str = "",
partial: str = "",
) -> None:
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다."""
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
if st.background_opacity > 0:
bg = QColor(st.background_color)
bg.setAlpha(int(255 * st.background_opacity / 100))
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(rect, 12, 12)
rows: list[tuple[str, QColor, int]] = []
if st.show_source and source and translated:
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
main = translated or partial
if main:
color = QColor(st.text_color)
if not translated: # 아직 확정 전 — 흐리게
color.setAlpha(170)
rows.append((main, color, st.font_size))
if not rows:
return
margin = 18
available = rect.width() - margin * 2
blocks: list[tuple[list[str], QColor, QFont]] = []
total_h = 0
for text, color, size in rows:
font = QFont(st.font_family, size)
font.setBold(st.bold)
lines = _wrap(text, QFontMetrics(font), available, st.max_lines)
blocks.append((lines, color, font))
total_h += len(lines) * (QFontMetrics(font).height() + st.line_spacing)
y = rect.y() + max(margin, (rect.height() - total_h) // 2)
for lines, color, font in blocks:
painter.setFont(font)
fm = QFontMetrics(font)
for line in lines:
width = fm.horizontalAdvance(line)
if st.align == "left":
x = rect.x() + margin
elif st.align == "right":
x = rect.right() - margin - width
else:
x = rect.x() + (rect.width() - width) // 2
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
y += fm.height() + st.line_spacing
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
"""외곽선 있는 글자.
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
"""
if st.outline_width <= 0:
painter.setPen(color)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawText(x, y, text)
return
path = QPainterPath()
path.addText(x, y, font, text)
pen = QPen(QColor(st.outline_color))
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPath(path)
class SubtitlePreview(QWidget):
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
super().__init__(parent)
self.style_cfg = style
self.translated = ""
self.source = ""
self.setMinimumHeight(140)
def set_sample(self, translated: str, source: str) -> None:
self.translated = translated
self.source = source
self.update()
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
paint_subtitle(
painter, self.rect(), self.style_cfg, self.translated, self.source
)
class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
super().__init__(None)
self.style_cfg = style
self.overlay_cfg = overlay
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self._drag_from: QPoint | None = None
self._resize_from: QRect | None = None
self._fade = QTimer(self)
self._fade.setSingleShot(True)
self._fade.timeout.connect(self._clear_text)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.Tool
| Qt.WindowType.WindowStaysOnTopHint
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMinimumSize(320, 90)
self.apply_config()
# --- 외부 API -------------------------------------------------------
def apply_config(self) -> None:
cfg = self.overlay_cfg
flags = self.windowFlags()
flags = (
flags | Qt.WindowType.WindowStaysOnTopHint
if cfg.always_on_top
else flags & ~Qt.WindowType.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
)
if cfg.x < 0 or cfg.y < 0:
screen = QApplication.primaryScreen()
area = screen.availableGeometry() if screen else QRect(0, 0, 1920, 1080)
cfg.width = min(cfg.width, area.width() - 80)
cfg.x = area.x() + (area.width() - cfg.width) // 2
cfg.y = area.y() + area.height() - cfg.height - 90
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
if cfg.visible:
self.show()
self.update()
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
if is_final:
self._final_text = translated or source
self._partial_text = ""
self._source_text = source
else:
self._partial_text = source
self._restart_fade()
self.update()
def clear(self) -> None:
self._clear_text()
# --- 그리기 ---------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
painter = QPainter(self)
paint_subtitle(
painter,
self.rect(),
self.style_cfg,
self._final_text,
self._source_text,
self._partial_text,
)
if not (self._final_text or self._partial_text) and not self.overlay_cfg.locked:
self._draw_placeholder(painter)
self._draw_grip(painter)
def _draw_placeholder(self, painter) -> None:
painter.setPen(QColor(255, 255, 255, 90))
font = QFont(self.style_cfg.font_family, 13)
painter.setFont(font)
painter.drawText(
self.rect(),
Qt.AlignmentFlag.AlignCenter,
"자막이 여기에 표시됩니다\n드래그로 이동 · 휠로 크기 · 우클릭으로 메뉴",
)
def _draw_grip(self, painter) -> None:
if self.overlay_cfg.locked or self.overlay_cfg.click_through:
return
painter.setPen(QPen(QColor(255, 255, 255, 110), 2))
w, h = self.width(), self.height()
for offset in (5, 10, 15):
painter.drawLine(w - offset, h - 4, w - 4, h - offset)
# --- 마우스 ---------------------------------------------------------
def mousePressEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked or event.button() != Qt.MouseButton.LeftButton:
return
pos = event.position().toPoint()
if pos.x() > self.width() - GRIP and pos.y() > self.height() - GRIP:
self._resize_from = QRect(event.globalPosition().toPoint(), self.size())
else:
self._drag_from = event.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, event) -> None: # noqa: N802
if self._resize_from is not None:
delta = event.globalPosition().toPoint() - self._resize_from.topLeft()
self.resize(
max(self.minimumWidth(), self._resize_from.width() + delta.x()),
max(self.minimumHeight(), self._resize_from.height() + delta.y()),
)
elif self._drag_from is not None:
self.move(event.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is not None or self._resize_from is not None:
self._drag_from = None
self._resize_from = None
g = self.geometry()
self.overlay_cfg.x, self.overlay_cfg.y = g.x(), g.y()
self.overlay_cfg.width, self.overlay_cfg.height = g.width(), g.height()
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked:
return
step = 2 if event.angleDelta().y() > 0 else -2
self.style_cfg.font_size = max(12, min(120, self.style_cfg.font_size + step))
self.update()
def contextMenuEvent(self, event) -> None: # noqa: N802
menu = QMenu(self)
cfg = self.overlay_cfg
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
lock.toggled.connect(self._set_locked)
menu.addAction(lock)
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
through.toggled.connect(self._set_click_through)
menu.addAction(through)
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
top.toggled.connect(self._set_always_on_top)
menu.addAction(top)
menu.addSeparator()
menu.addAction("자막 지우기", self.clear)
menu.addAction("자막 창 숨기기", self.hide_overlay)
menu.exec(event.globalPos())
# --- 메뉴 동작 ------------------------------------------------------
def _set_locked(self, value: bool) -> None:
self.overlay_cfg.locked = value
self.update()
def _set_click_through(self, value: bool) -> None:
self.overlay_cfg.click_through = value
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, value)
self.update()
def _set_always_on_top(self, value: bool) -> None:
self.overlay_cfg.always_on_top = value
self.apply_config()
def hide_overlay(self) -> None:
self.overlay_cfg.visible = False
self.hide()
self.closed.emit()
def _restart_fade(self) -> None:
self._fade.stop()
if self.style_cfg.fade_out_ms > 0:
self._fade.start(self.style_cfg.fade_out_ms)
def _clear_text(self) -> None:
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self.update()
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
if width <= 0 or not text:
return [text] if text else []
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if fm.horizontalAdvance(candidate) <= width or not current:
current = candidate
else:
lines.append(current)
current = word
if current:
lines.append(current)
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
return lines[-max_lines:] if max_lines > 0 else lines

View File

@@ -0,0 +1,7 @@
from .glossary_page import GlossaryPage
from .home import HomePage
from .models_page import ModelsPage
from .settings_page import SettingsPage
from .subtitle_page import SubtitlePage
__all__ = ["GlossaryPage", "HomePage", "ModelsPage", "SettingsPage", "SubtitlePage"]

View File

@@ -0,0 +1,256 @@
"""용어집 — 게임·방송 고유명사를 원하는 번역으로 고정한다."""
from __future__ import annotations
import csv
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QFileDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry
from ..theme import SPACING
from ..widgets import Card, PageHeader
COLUMNS = ["원문 용어", *[f"{LANGUAGES[c]['label']} 역어" for c in LANGUAGE_CODES], "메모"]
class GlossaryPage(QWidget):
glossary_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self.path = Path(config.glossary.path or user_data_dir() / "glossary.json")
self.glossary = Glossary.load(self.path, config.glossary.case_sensitive)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"용어집",
"여기 등록한 단어는 번역 모델이 마음대로 바꾸지 못하고 지정한 역어로 고정됩니다. "
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
)
)
root.addWidget(self._build_options_card())
root.addWidget(self._build_table_card(), 1)
self._reload_table()
# --- 구성 -----------------------------------------------------------
def _build_options_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.enabled_check = QCheckBox("용어집 사용")
self.enabled_check.setChecked(self.config.glossary.enabled)
self.enabled_check.toggled.connect(self._on_enabled)
layout.addWidget(self.enabled_check)
self.case_check = QCheckBox("대소문자 구분")
self.case_check.setChecked(self.config.glossary.case_sensitive)
self.case_check.toggled.connect(self._on_case)
layout.addWidget(self.case_check)
layout.addStretch(1)
for label, slot in (
("CSV 가져오기", self._import_csv),
("CSV 내보내기", self._export_csv),
):
button = QPushButton(label)
button.clicked.connect(slot)
layout.addWidget(button)
card.add(row)
return card
def _build_table_card(self) -> Card:
card = Card(
"등록된 용어",
"역어를 비워두면 그 언어에서는 규칙을 적용하지 않습니다. "
"CSV 형식: 원문,한국어,English,日本語,中文,메모",
)
search_row = QWidget()
search_layout = QHBoxLayout(search_row)
search_layout.setContentsMargins(0, 0, 0, 0)
search_layout.setSpacing(10)
self.search = QLineEdit()
self.search.setPlaceholderText("용어 검색…")
self.search.textChanged.connect(self._filter)
search_layout.addWidget(self.search, 1)
for label, slot in (("행 추가", self._add_row), ("선택 삭제", self._delete_rows)):
button = QPushButton(label)
button.clicked.connect(slot)
search_layout.addWidget(button)
card.add(search_row)
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table.itemChanged.connect(self._on_item_changed)
card.add(self.table)
self.count_label = card.add(
_hint(f"{len(self.glossary)}개 등록됨")
)
return card
# --- 데이터 ---------------------------------------------------------
def _reload_table(self) -> None:
self.table.blockSignals(True)
self.table.setRowCount(0)
for entry in self.glossary.entries:
self._append_row(entry)
self.table.blockSignals(False)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
def _append_row(self, entry: GlossaryEntry) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
values = [entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
for col, value in enumerate(values):
self.table.setItem(row, col, QTableWidgetItem(value))
def _collect(self) -> list[GlossaryEntry]:
entries: list[GlossaryEntry] = []
for row in range(self.table.rowCount()):
source_item = self.table.item(row, 0)
source = source_item.text().strip() if source_item else ""
if not source:
continue
targets: dict[str, str] = {}
for i, code in enumerate(LANGUAGE_CODES, start=1):
item = self.table.item(row, i)
text = item.text().strip() if item else ""
if text:
targets[code] = text
note_item = self.table.item(row, len(COLUMNS) - 1)
entries.append(
GlossaryEntry(
source=source,
targets=targets,
note=note_item.text().strip() if note_item else "",
)
)
return entries
def _save(self) -> None:
self.glossary = Glossary(self._collect(), self.config.glossary.case_sensitive)
self.glossary.save(self.path)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
self.glossary_changed.emit()
# --- 동작 -----------------------------------------------------------
def _on_item_changed(self, *_) -> None:
self._save()
def _on_enabled(self, value: bool) -> None:
self.config.glossary.enabled = value
self.glossary_changed.emit()
def _on_case(self, value: bool) -> None:
self.config.glossary.case_sensitive = value
self._save()
def _add_row(self) -> None:
self.table.blockSignals(True)
self._append_row(GlossaryEntry(source=""))
self.table.blockSignals(False)
self.table.editItem(self.table.item(self.table.rowCount() - 1, 0))
def _delete_rows(self) -> None:
rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
if not rows:
return
self.table.blockSignals(True)
for row in rows:
self.table.removeRow(row)
self.table.blockSignals(False)
self._save()
def _filter(self, text: str) -> None:
needle = text.strip().casefold()
for row in range(self.table.rowCount()):
haystack = " ".join(
(self.table.item(row, c).text() if self.table.item(row, c) else "")
for c in range(self.table.columnCount())
).casefold()
self.table.setRowHidden(row, bool(needle) and needle not in haystack)
def _import_csv(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "CSV 가져오기", "", "CSV (*.csv)")
if not path:
return
try:
with open(path, encoding="utf-8-sig", newline="") as fh:
rows = [r for r in csv.reader(fh) if r and r[0].strip()]
except OSError as exc:
QMessageBox.warning(self, "가져오기 실패", str(exc))
return
# 첫 줄이 헤더처럼 보이면 건너뛴다.
if rows and rows[0][0].strip() in ("원문", "원문 용어", "source"):
rows = rows[1:]
added = 0
for row in rows:
cells = [c.strip() for c in row] + [""] * (len(COLUMNS) - len(row))
targets = {
code: cells[i]
for i, code in enumerate(LANGUAGE_CODES, start=1)
if cells[i]
}
self.glossary.add(
GlossaryEntry(source=cells[0], targets=targets, note=cells[len(COLUMNS) - 1])
)
added += 1
self.glossary.save(self.path)
self._reload_table()
self.glossary_changed.emit()
QMessageBox.information(self, "가져오기 완료", f"{added}개 용어를 반영했습니다.")
def _export_csv(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "CSV 내보내기", "glossary.csv", "CSV (*.csv)"
)
if not path:
return
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(COLUMNS)
for entry in self.glossary.entries:
writer.writerow(
[entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
)
def _hint(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("Hint")
label.setAlignment(Qt.AlignmentFlag.AlignRight)
return label

226
src/hearo/ui/pages/home.py Normal file
View File

@@ -0,0 +1,226 @@
"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from ...audio import capture_capabilities, list_sources
from ...config import AppConfig
from ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine
from ..theme import SPACING, palette
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
MAX_LOG_ROWS = 300
class HomePage(QWidget):
start_requested = Signal()
stop_requested = Signal()
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._sources: list = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
)
root.addWidget(self._build_source_card())
root.addWidget(self._build_control_card())
root.addWidget(self._build_log_card(), 1)
self.refresh_sources()
# --- 구성 -----------------------------------------------------------
def _build_source_card(self) -> Card:
card = Card("소리 받아올 곳", "")
self.capability_label = QLabel()
self.capability_label.setObjectName("Hint")
self.capability_label.setWordWrap(True)
card.add(self.capability_label)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(360)
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
layout.addWidget(self.source_combo, 1)
refresh = QPushButton("새로고침")
refresh.clicked.connect(self.refresh_sources)
layout.addWidget(refresh)
card.add(row)
return card
def _build_control_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
self.source_lang = QComboBox()
self.source_lang.addItem("자동 감지", "auto")
for code, meta in LANGUAGES.items():
self.source_lang.addItem(meta["label"], code)
self.source_lang.setCurrentIndex(
max(0, self.source_lang.findData(self.config.models.source_lang))
)
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
self.target_lang = QComboBox()
for code, meta in LANGUAGES.items():
self.target_lang.addItem(meta["label"], code)
self.target_lang.setCurrentIndex(
max(0, self.target_lang.findData(self.config.models.target_lang))
)
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
layout.addWidget(QLabel("원본"))
layout.addWidget(self.source_lang)
arrow = QLabel("→")
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
layout.addWidget(arrow)
layout.addWidget(QLabel("번역"))
layout.addWidget(self.target_lang)
layout.addStretch(1)
self.status_pill = StatusPill()
layout.addWidget(self.status_pill)
self.toggle_button = QPushButton("번역 시작")
self.toggle_button.setObjectName("Primary")
self.toggle_button.setMinimumWidth(140)
self.toggle_button.clicked.connect(self._on_toggle)
layout.addWidget(self.toggle_button)
card.add(row)
self.level_meter = LevelMeter()
card.add(self.level_meter)
return card
def _build_log_card(self) -> Card:
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
self.log_list = QListWidget()
self.log_list.setWordWrap(True)
self.log_list.setAlternatingRowColors(False)
card.add(self.log_list)
return card
# --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None:
caps = capture_capabilities()
self._sources = list_sources()
self.source_combo.blockSignals(True)
self.source_combo.clear()
for src in self._sources:
prefix = "🎮" if src.kind == "process" else "🔊"
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
if not self._sources:
self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None)
self._select_saved_source()
self.source_combo.blockSignals(False)
self._on_source_changed()
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(hearo_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)
else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
self.capability_label.setText(msg)
def _select_saved_source(self) -> None:
cfg = self.config.audio
for i, src in enumerate(self._sources):
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
self.source_combo.setCurrentIndex(i)
return
if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index:
self.source_combo.setCurrentIndex(i)
return
def _on_source_changed(self, *_) -> None:
src = self.source_combo.currentData()
cfg = self.config.audio
if src is None:
return
if src.kind == "process":
cfg.backend = "process"
cfg.target_pid = src.pid
cfg.target_process_name = src.label
else:
cfg.backend = "device"
cfg.device_index = src.device_index
cfg.target_pid = 0
self.config_changed.emit()
def _on_lang_changed(self, *_) -> None:
self.config.models.source_lang = self.source_lang.currentData()
self.config.models.target_lang = self.target_lang.currentData()
self.config_changed.emit()
def _on_toggle(self) -> None:
if self.toggle_button.property("running"):
self.stop_requested.emit()
else:
self.start_requested.emit()
# --- 엔진 연동 ------------------------------------------------------
def on_status(self, status: EngineStatus) -> None:
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
self.toggle_button.setProperty("running", running)
self.toggle_button.setText("번역 정지" if running else "번역 시작")
self.toggle_button.setObjectName("Danger" if running else "Primary")
self.toggle_button.style().polish(self.toggle_button)
kind = {
EngineState.RUNNING: "ok",
EngineState.LOADING: "busy",
EngineState.STOPPING: "busy",
EngineState.ERROR: "error",
}.get(status.state, "idle")
self.status_pill.set_status(status.message or status.state.value, kind)
self.level_meter.set_level(status.level)
for widget in (self.source_combo, self.source_lang, self.target_lang):
widget.setEnabled(not running)
def on_line(self, line: TranslationLine) -> None:
if not line.is_final:
return
item = QListWidgetItem(
f"{line.translated_text}\n"
f" {line.source_text} · {line.latency_s:.1f}초 "
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
)
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
item.setToolTip(line.source_text)
self.log_list.insertItem(0, item)
while self.log_list.count() > MAX_LOG_ROWS:
self.log_list.takeItem(self.log_list.count() - 1)
self.log_list.scrollToTop()

View File

@@ -0,0 +1,221 @@
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...models import Tier, detect_gpu, ordered_tiers
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class TierCard(QFrame):
"""티어 하나를 라디오 버튼 카드로."""
def __init__(self, tier: Tier, gpu_vram_gb: float, parent: QWidget | None = None):
super().__init__(parent)
self.tier = tier
self.setObjectName("Card")
p = palette("dark")
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 14)
layout.setSpacing(14)
self.radio = QRadioButton()
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
text = QVBoxLayout()
text.setSpacing(3)
head = QHBoxLayout()
head.setSpacing(8)
name = QLabel(f"{tier.order}. {tier.name}")
name.setStyleSheet("font-size: 16px; font-weight: 700;")
head.addWidget(name)
if tier.recommended:
head.addWidget(_badge("추천", p.accent))
if tier.best_after_finetune:
head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
head.addStretch(1)
text.addLayout(head)
tagline = QLabel(tier.tagline)
tagline.setObjectName("Subtitle")
text.addWidget(tagline)
spec = QLabel(
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
f"· 내려받기 약 {tier.download_gb:g}GB"
)
spec.setObjectName("Hint")
text.addWidget(spec)
if tier.notes:
note = QLabel(tier.notes)
note.setObjectName("Hint")
note.setWordWrap(True)
text.addWidget(note)
layout.addLayout(text, 1)
def set_selected(self, selected: bool) -> None:
self.setObjectName("CardSelected" if selected else "Card")
self.style().unpolish(self)
self.style().polish(self)
class ModelsPage(QWidget):
tier_changed = Signal(str)
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._cards: list[TierCard] = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"모델",
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
"모델은 처음 사용할 때 자동으로 내려받습니다.",
)
)
root.addWidget(self._build_gpu_card())
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for tier in ordered_tiers():
card = TierCard(tier, self._gpu_vram_gb)
self._group.addButton(card.radio, tier.order)
card.radio.toggled.connect(
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
)
self._cards.append(card)
root.addWidget(card)
root.addWidget(self._build_lora_card())
root.addStretch(1)
self._apply_selection(config.models.tier)
# --- 구성 -----------------------------------------------------------
def _build_gpu_card(self) -> Card:
gpu = detect_gpu()
self._gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
p = palette("dark")
card = Card("실행 환경")
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
if gpu.available:
text = (
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
)
color = p.success
else:
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
color = p.warning
info = QLabel(text)
info.setStyleSheet(f"color: {color}; font-weight: 600;")
info.setWordWrap(True)
layout.addWidget(info, 1)
self.device_combo = QComboBox()
self.device_combo.addItem("GPU (CUDA)", "cuda")
self.device_combo.addItem("CPU", "cpu")
self.device_combo.setCurrentIndex(
max(0, self.device_combo.findData(self.config.models.device))
)
self.device_combo.setEnabled(gpu.available)
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
layout.addWidget(self.device_combo)
card.add(row)
return card
def _build_lora_card(self) -> Card:
card = Card(
"추가학습 어댑터 (선택)",
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
"scripts/finetune_mt.py 로 만들 수 있습니다.",
)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
self.lora_edit.editingFinished.connect(self._on_lora_changed)
layout.addWidget(self.lora_edit, 1)
browse = QPushButton("폴더 선택")
browse.clicked.connect(self._browse_lora)
layout.addWidget(browse)
card.add(row)
return card
# --- 동작 -----------------------------------------------------------
def _apply_selection(self, tier_key: str) -> None:
for card in self._cards:
selected = card.tier.key == tier_key
card.radio.setChecked(selected)
card.set_selected(selected)
def _on_tier_selected(self, tier: Tier) -> None:
if self.config.models.tier == tier.key:
return
self.config.models.tier = tier.key
for card in self._cards:
card.set_selected(card.tier.key == tier.key)
self.tier_changed.emit(tier.key)
self.config_changed.emit()
def _on_device_changed(self) -> None:
self.config.models.device = self.device_combo.currentData()
self.config_changed.emit()
def _on_lora_changed(self) -> None:
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
self.config_changed.emit()
def _browse_lora(self) -> None:
from PySide6.QtWidgets import QFileDialog
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
if path:
self.lora_edit.setText(path)
self._on_lora_changed()
def _badge(text: str, color: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet(
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
)
return label

View File

@@ -0,0 +1,176 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..theme import SPACING
from ..widgets import Card, PageHeader
class SettingsPage(QWidget):
config_changed = Signal()
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
restart_needed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(PageHeader("설정", "인식 민감도와 응답 속도를 조절합니다."))
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
# --- 구성 -----------------------------------------------------------
def _build_audio_card(self) -> Card:
cfg = self.config.audio
card = Card(
"음성 인식",
"소리가 작아 인식이 안 되면 입력 증폭을 올리고, 주변 소음까지 잡히면 "
"민감도를 낮추세요. 값을 바꾸면 다음 시작부터 적용됩니다.",
)
form = card.add_form()
self.gain_slider, gain_row = _slider_row(
int(cfg.input_gain * 100), 25, 400, "%", self._on_audio_changed
)
form.addRow("입력 증폭", gain_row)
self.vad_combo = QComboBox()
for label, value in (
("낮음 — 작은 소리도 잡음", 0),
("보통", 1),
("높음 (기본)", 2),
("매우 높음 — 또렷한 말만", 3),
):
self.vad_combo.addItem(label, value)
self.vad_combo.setCurrentIndex(max(0, self.vad_combo.findData(cfg.vad_aggressiveness)))
self.vad_combo.currentIndexChanged.connect(self._on_audio_changed)
form.addRow("잡음 억제", self.vad_combo)
self.silence_spin = _spin(cfg.silence_ms, 200, 2000, 50, " ms", self._on_audio_changed)
self.silence_spin.setToolTip(
"이 시간만큼 조용하면 한 문장이 끝난 것으로 봅니다. "
"짧게 하면 자막이 빨리 뜨지만 문장이 자주 끊깁니다."
)
form.addRow("문장 끊는 침묵", self.silence_spin)
self.max_spin = _spin(cfg.max_segment_ms, 3000, 30_000, 1000, " ms", self._on_audio_changed)
self.max_spin.setToolTip("말이 계속 이어져도 이 길이에서 강제로 끊습니다.")
form.addRow("문장 최대 길이", self.max_spin)
self.partial_spin = _spin(
cfg.partial_interval_ms, 0, 3000, 100, " ms", self._on_audio_changed
)
self.partial_spin.setToolTip(
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다. "
"0이면 끕니다. GPU 부하가 조금 늘어납니다."
)
form.addRow("중간 결과 갱신", self.partial_spin)
return card
def _build_behavior_card(self) -> Card:
card = Card("동작")
form = card.add_form()
self.theme_combo = QComboBox()
self.theme_combo.addItem("어두운 테마", "dark")
self.theme_combo.addItem("밝은 테마", "light")
self.theme_combo.setCurrentIndex(max(0, self.theme_combo.findData(self.config.theme)))
self.theme_combo.currentIndexChanged.connect(self._on_behavior_changed)
form.addRow("테마", self.theme_combo)
self.preload_check = QCheckBox("시작할 때 모델을 미리 올려두기")
self.preload_check.setChecked(self.config.models.preload_on_start)
self.preload_check.setToolTip(
"끄면 프로그램이 빨리 뜨지만 첫 문장 번역이 오래 걸립니다."
)
self.preload_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.preload_check)
self.log_check = QCheckBox("번역 기록을 파일로 남기기")
self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card
def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
path = QLabel(f"설정 위치: {user_data_dir()}")
path.setObjectName("Hint")
path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
path.setWordWrap(True)
card.add(path)
return card
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:
cfg = self.config.audio
cfg.input_gain = self.gain_slider.value() / 100
cfg.vad_aggressiveness = self.vad_combo.currentData()
cfg.silence_ms = self.silence_spin.value()
cfg.max_segment_ms = self.max_spin.value()
cfg.partial_interval_ms = self.partial_spin.value()
self.config_changed.emit()
self.restart_needed.emit()
def _on_behavior_changed(self, *_) -> None:
self.config.theme = self.theme_combo.currentData()
self.config.models.preload_on_start = self.preload_check.isChecked()
self.config.log_transcripts = self.log_check.isChecked()
self.config_changed.emit()
def _open_folder(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
spin = QSpinBox()
spin.setRange(low, high)
spin.setSingleStep(step)
spin.setSuffix(suffix)
spin.setValue(value)
spin.valueChanged.connect(slot)
return spin
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(low, high)
slider.setValue(value)
label = QLabel(f"{value}{suffix}")
label.setMinimumWidth(48)
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
slider.valueChanged.connect(slot)
layout.addWidget(slider, 1)
layout.addWidget(label)
return slider, row

View File

@@ -0,0 +1,216 @@
"""자막 — 글꼴·색·크기 등 표시 설정과 실시간 미리보기."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QFontComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ..overlay import SubtitlePreview
from ..theme import SPACING
from ..widgets import Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
class ColorButton(QPushButton):
color_picked = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None):
super().__init__(parent)
self.setFixedSize(52, 30)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_color(color)
self.clicked.connect(self._pick)
@property
def color(self) -> str:
return self._color
def set_color(self, color: str) -> None:
self._color = color
self.setStyleSheet(
f"background: {color}; border: 1px solid #3A4152; border-radius: 6px;"
)
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "색 선택")
if chosen.isValid():
self.set_color(chosen.name())
self.color_picked.emit(chosen.name())
class SubtitlePage(QWidget):
style_changed = Signal()
overlay_toggled = Signal(bool)
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("자막", "자막 창에 표시될 글씨의 모양을 정합니다. 아래에서 바로 확인하세요.")
)
root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card())
root.addStretch(1)
self._sync_preview()
# --- 구성 -----------------------------------------------------------
def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
self.preview.set_sample(PREVIEW_TEXT, PREVIEW_SOURCE)
card.add(self.preview)
return card
def _build_text_card(self) -> Card:
st = self.config.subtitle
card = Card("글씨")
form = card.add_form()
self.font_combo = QFontComboBox()
available = set(QFontDatabase.families())
self.font_combo.setCurrentFont(
QFont(st.font_family if st.font_family in available else "Segoe UI")
)
self.font_combo.currentFontChanged.connect(self._on_font_changed)
form.addRow("글꼴", self.font_combo)
self.size_spin = QSpinBox()
self.size_spin.setRange(12, 120)
self.size_spin.setSuffix(" px")
self.size_spin.setValue(st.font_size)
self.size_spin.valueChanged.connect(self._on_change)
form.addRow("크기", self.size_spin)
self.bold_check = QCheckBox("굵게")
self.bold_check.setChecked(st.bold)
self.bold_check.toggled.connect(self._on_change)
form.addRow("", self.bold_check)
self.text_color = ColorButton(st.text_color)
self.text_color.color_picked.connect(self._on_change)
form.addRow("번역문 색", self.text_color)
self.source_color = ColorButton(st.source_text_color)
self.source_color.color_picked.connect(self._on_change)
form.addRow("원문 색", self.source_color)
self.outline_color = ColorButton(st.outline_color)
self.outline_color.color_picked.connect(self._on_change)
form.addRow("외곽선 색", self.outline_color)
self.outline_spin = QSpinBox()
self.outline_spin.setRange(0, 8)
self.outline_spin.setSuffix(" px")
self.outline_spin.setValue(st.outline_width)
self.outline_spin.setToolTip("배경이 밝든 어둡든 글씨가 읽히게 해줍니다. 0이면 끕니다.")
self.outline_spin.valueChanged.connect(self._on_change)
form.addRow("외곽선 두께", self.outline_spin)
return card
def _build_layout_card(self) -> Card:
st = self.config.subtitle
card = Card("배치와 배경")
form = card.add_form()
self.bg_color = ColorButton(st.background_color)
self.bg_color.color_picked.connect(self._on_change)
form.addRow("배경 색", self.bg_color)
opacity_row = QWidget()
layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 100)
self.opacity_slider.setValue(st.background_opacity)
self.opacity_slider.valueChanged.connect(self._on_change)
self.opacity_value = QLabel(f"{st.background_opacity}%")
self.opacity_value.setMinimumWidth(42)
layout.addWidget(self.opacity_slider, 1)
layout.addWidget(self.opacity_value)
form.addRow("배경 투명도", opacity_row)
self.align_combo = QComboBox()
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
self.align_combo.addItem(label, value)
self.align_combo.setCurrentIndex(max(0, self.align_combo.findData(st.align)))
self.align_combo.currentIndexChanged.connect(self._on_change)
form.addRow("정렬", self.align_combo)
self.lines_spin = QSpinBox()
self.lines_spin.setRange(1, 5)
self.lines_spin.setSuffix(" 줄")
self.lines_spin.setValue(st.max_lines)
self.lines_spin.valueChanged.connect(self._on_change)
form.addRow("최대 줄 수", self.lines_spin)
self.fade_spin = QSpinBox()
self.fade_spin.setRange(0, 30_000)
self.fade_spin.setSingleStep(500)
self.fade_spin.setSuffix(" ms")
self.fade_spin.setValue(st.fade_out_ms)
self.fade_spin.setToolTip("이 시간이 지나면 자막을 지웁니다. 0이면 계속 남겨둡니다.")
self.fade_spin.valueChanged.connect(self._on_change)
form.addRow("자동 지우기", self.fade_spin)
self.show_source_check = QCheckBox("원문도 같이 표시")
self.show_source_check.setChecked(st.show_source)
self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check)
self.overlay_check = QCheckBox("자막 창 보이기")
self.overlay_check.setChecked(self.config.overlay.visible)
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
form.addRow("", self.overlay_check)
return card
# --- 동작 -----------------------------------------------------------
def _on_font_changed(self, font: QFont) -> None:
self.config.subtitle.font_family = font.family()
self._on_change()
def _on_change(self, *_) -> None:
st = self.config.subtitle
st.font_size = self.size_spin.value()
st.bold = self.bold_check.isChecked()
st.text_color = self.text_color.color
st.source_text_color = self.source_color.color
st.outline_color = self.outline_color.color
st.outline_width = self.outline_spin.value()
st.background_color = self.bg_color.color
st.background_opacity = self.opacity_slider.value()
st.align = self.align_combo.currentData()
st.max_lines = self.lines_spin.value()
st.fade_out_ms = self.fade_spin.value()
st.show_source = self.show_source_check.isChecked()
self.opacity_value.setText(f"{st.background_opacity}%")
self._sync_preview()
self.style_changed.emit()
def _sync_preview(self) -> None:
# 미리보기는 설정 객체를 그대로 참조하므로 다시 그리기만 하면 된다.
self.preview.setMinimumHeight(max(140, self.config.subtitle.font_size * 4))
self.preview.update()

209
src/hearo/ui/theme.py Normal file
View File

@@ -0,0 +1,209 @@
"""디자인 토큰과 QSS 스타일시트.
의도적으로 외부 테마 패키지를 쓰지 않는다. 자막 오버레이가 반투명·무테두리라
플랫폼 기본 테마와 섞이면 오히려 지저분해지고, 색/간격을 한곳에서 관리하는 편이
설정 화면의 '미리보기'와 실제 자막을 일치시키기에도 좋다.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Palette:
bg: str
surface: str
surface_alt: str
border: str
text: str
text_dim: str
accent: str
accent_hover: str
accent_text: str
success: str
warning: str
danger: str
DARK = Palette(
bg="#0F1117",
surface="#171A22",
surface_alt="#1E222C",
border="#2A2F3C",
text="#E7EAF0",
text_dim="#8B93A5",
accent="#5B8CFF",
accent_hover="#7AA2FF",
accent_text="#0B0E14",
success="#3DD68C",
warning="#F5B84E",
danger="#F2555A",
)
LIGHT = Palette(
bg="#F4F6FA",
surface="#FFFFFF",
surface_alt="#EDF0F6",
border="#D9DEE9",
text="#131722",
text_dim="#5C6578",
accent="#3B6EF0",
accent_hover="#2A5AD6",
accent_text="#FFFFFF",
success="#17A46A",
warning="#C9821B",
danger="#D63A3F",
)
RADIUS = 10
SPACING = 12
def palette(theme: str) -> Palette:
return LIGHT if theme == "light" else DARK
def stylesheet(theme: str = "dark") -> str:
p = palette(theme)
return f"""
QWidget {{
background: {p.bg};
color: {p.text};
font-family: "Pretendard", "Segoe UI Variable", "Segoe UI", "Malgun Gothic", sans-serif;
font-size: 14px;
}}
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget {{
background: transparent;
}}
QLabel#Title {{ font-size: 22px; font-weight: 700; }}
QLabel#Subtitle {{ font-size: 13px; color: {p.text_dim}; }}
QLabel#SectionLabel {{ font-size: 12px; font-weight: 700; color: {p.text_dim};
letter-spacing: 1px; }}
QLabel#Hint {{ font-size: 12px; color: {p.text_dim}; }}
/* --- 사이드바 --- */
QFrame#Sidebar {{
background: {p.surface};
border-right: 1px solid {p.border};
}}
QPushButton#NavButton {{
background: transparent;
border: none;
border-radius: {RADIUS}px;
padding: 11px 14px;
text-align: left;
color: {p.text_dim};
font-size: 14px;
}}
QPushButton#NavButton:hover {{ background: {p.surface_alt}; color: {p.text}; }}
QPushButton#NavButton:checked {{
background: {p.accent};
color: {p.accent_text};
font-weight: 700;
}}
/* --- 카드 --- */
QFrame#Card {{
background: {p.surface};
border: 1px solid {p.border};
border-radius: {RADIUS}px;
}}
QFrame#CardSelected {{
background: {p.surface};
border: 2px solid {p.accent};
border-radius: {RADIUS}px;
}}
/* --- 입력 위젯 --- */
QComboBox, QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit, QListWidget, QTableWidget {{
background: {p.surface_alt};
border: 1px solid {p.border};
border-radius: 8px;
padding: 7px 10px;
selection-background-color: {p.accent};
selection-color: {p.accent_text};
}}
QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus {{
border: 1px solid {p.accent};
}}
QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{
background: {p.surface_alt};
border: 1px solid {p.border};
selection-background-color: {p.accent};
outline: none;
}}
QHeaderView::section {{
background: {p.surface};
color: {p.text_dim};
border: none;
border-bottom: 1px solid {p.border};
padding: 7px;
font-weight: 600;
}}
QTableWidget {{ gridline-color: {p.border}; }}
/* --- 버튼 --- */
QPushButton {{
background: {p.surface_alt};
border: 1px solid {p.border};
border-radius: 8px;
padding: 8px 16px;
color: {p.text};
}}
QPushButton:hover {{ border-color: {p.accent}; }}
QPushButton:disabled {{ color: {p.text_dim}; border-color: {p.border}; }}
QPushButton#Primary {{
background: {p.accent};
color: {p.accent_text};
border: none;
font-weight: 700;
padding: 11px 22px;
}}
QPushButton#Primary:hover {{ background: {p.accent_hover}; }}
QPushButton#Primary:disabled {{ background: {p.border}; color: {p.text_dim}; }}
QPushButton#Danger {{
background: {p.danger};
color: #FFFFFF;
border: none;
font-weight: 700;
padding: 11px 22px;
}}
/* --- 기타 --- */
QSlider::groove:horizontal {{
height: 4px; background: {p.border}; border-radius: 2px;
}}
QSlider::handle:horizontal {{
width: 16px; height: 16px; margin: -6px 0;
background: {p.accent}; border-radius: 8px;
}}
QSlider::sub-page:horizontal {{ background: {p.accent}; border-radius: 2px; }}
QProgressBar {{
background: {p.surface_alt}; border: none; border-radius: 4px;
height: 6px; text-align: center; color: transparent;
}}
QProgressBar::chunk {{ background: {p.accent}; border-radius: 4px; }}
QCheckBox::indicator, QRadioButton::indicator {{
width: 17px; height: 17px;
border: 1px solid {p.border}; border-radius: 4px;
background: {p.surface_alt};
}}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
background: {p.accent}; border-color: {p.accent};
}}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
QScrollBar::handle:vertical {{
background: {p.border}; border-radius: 5px; min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{ background: {p.text_dim}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QScrollArea {{ border: none; }}
QToolTip {{
background: {p.surface_alt}; color: {p.text};
border: 1px solid {p.border}; padding: 6px; border-radius: 6px;
}}
"""

View File

@@ -0,0 +1,3 @@
from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row
__all__ = ["Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"]

View File

@@ -0,0 +1,136 @@
"""여러 화면에서 재사용하는 작은 위젯들."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import (
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..theme import SPACING, palette
class Card(QFrame):
"""제목 + 본문 레이아웃을 가진 카드 컨테이너."""
def __init__(self, title: str = "", subtitle: str = "", parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("Card")
outer = QVBoxLayout(self)
outer.setContentsMargins(SPACING + 4, SPACING + 4, SPACING + 4, SPACING + 4)
outer.setSpacing(SPACING)
if title:
label = QLabel(title)
label.setStyleSheet("font-size: 15px; font-weight: 700;")
outer.addWidget(label)
if subtitle:
hint = QLabel(subtitle)
hint.setObjectName("Hint")
hint.setWordWrap(True)
outer.addWidget(hint)
self.body = QVBoxLayout()
self.body.setSpacing(SPACING)
outer.addLayout(self.body)
def add(self, widget: QWidget) -> QWidget:
self.body.addWidget(widget)
return widget
def add_form(self) -> QFormLayout:
form = QFormLayout()
form.setSpacing(10)
form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.body.addLayout(form)
return form
class PageHeader(QWidget):
def __init__(self, title: str, subtitle: str = "", parent: QWidget | None = None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
head = QLabel(title)
head.setObjectName("Title")
layout.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Subtitle")
sub.setWordWrap(True)
layout.addWidget(sub)
class StatusPill(QLabel):
"""상태를 색 점 + 문구로 보여주는 배지."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._color = "#8B93A5"
self.setContentsMargins(10, 6, 12, 6)
self.set_status("대기 중", "idle")
def set_status(self, text: str, kind: str = "idle") -> None:
p = palette("dark")
self._color = {
"idle": p.text_dim,
"busy": p.warning,
"ok": p.success,
"error": p.danger,
}.get(kind, p.text_dim)
self.setText(f"● {text}")
self.setStyleSheet(
f"color: {self._color}; background: {p.surface_alt};"
f"border-radius: 13px; padding: 6px 14px; font-weight: 600;"
)
class LevelMeter(QWidget):
"""입력 오디오 레벨 바. 소리가 잡히는지 한눈에 확인하는 용도."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._level = 0.0
self._peak = 0.0
self.setFixedHeight(8)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
def set_level(self, value: float) -> None:
# RMS를 그대로 그리면 거의 안 움직여서 제곱근으로 펴준다.
self._level = min(1.0, max(0.0, value) ** 0.5 * 2.2)
self._peak = max(self._level, self._peak * 0.92)
self.update()
def paintEvent(self, event) -> None: # noqa: N802
p = palette("dark")
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(p.surface_alt))
painter.drawRoundedRect(self.rect(), 4, 4)
if self._level > 0.01:
color = QColor(p.danger if self._level > 0.92 else p.success)
rect = self.rect().adjusted(0, 0, -int(self.width() * (1 - self._level)), 0)
painter.setBrush(color)
painter.drawRoundedRect(rect, 4, 4)
def labelled_row(label: str, widget: QWidget) -> QWidget:
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
text = QLabel(label)
text.setMinimumWidth(110)
layout.addWidget(text)
layout.addWidget(widget, 1)
return row