Files
live-app-translator/src/hearo/ui/overlay.py
EJClaw 1a87ec6677 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>
2026-09-21 10:47:11 +09:00

347 lines
12 KiB
Python

"""자막 오버레이 창.
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
조작:
* 드래그 — 위치 이동
* 우하단 모서리 — 크기 조절
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
"""
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