feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집

이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다.

자막 on/off
- 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey +
  네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op
- 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합

모니터 선택 + 디스코드식 배치
- placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표)
- AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환
- 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프

GPU 저부하 모드 (기본 켜짐)
- 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보,
  300초 무음 시 모델 언로드, 중간 결과 비활성
- 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향
- 8GB GPU 기준 자막 2.8GB / 게임 5.2GB

게임 용어집 번들 (304개)
- 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38
- 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선

검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-09-21 17:49:26 +09:00
parent 5596f905d1
commit 8b36b244ff
60 changed files with 1939 additions and 300 deletions

108
README.md
View File

@@ -1,37 +1,75 @@
# Hearo — 듣고, 바로 이해하다 # LiveSub — 게임 소리를 실시간 자막으로
특정 프로그램에서 나오는 소리를 실시간으로 받아 번역해 자막으로 보여줍니다. 게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다.
번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다. 인터넷도, API 키도 필요 없습니다. 디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다.
인터넷도, API 키도 필요 없습니다.
한국어 · English · 日本語 · 中文 사이를 번역합니다. 한국어 · English · 日本語 · 中文 사이를 번역합니다.
![홈 화면](docs/images/home.png) ![자막 오버레이](docs/images/overlay.png)
--- ---
## 무엇을 하는 프로그램인가 ## 무엇을 하는 프로그램인가
게임을 하는데 영어 음성만 나올 때, 해외 방송을 보는데 자막이 없을 때, 게임을 하는데 영어 음성만 나올 때, 해외 방송을 보는데 자막이 없을 때,
그 프로그램을 골라서 `번역 시작`만 누르면 화면 아래에 한국어 자막이 뜹니다. 그 프로그램을 골라서 `번역 시작`만 누르면 화면에 한국어 자막이 뜹니다.
``` ```
게임 소리 → 음성인식 → 번역 → 화면 위 자막 게임 소리 → 음성인식 → 번역 → 화면 위 자막
(Whisper) (Seed-X / NLLB) (Whisper) (Seed-X / NLLB)
``` ```
![자막 오버레이](docs/images/overlay.png) ![홈 화면](docs/images/home.png)
## 주요 기능 ## 주요 기능
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다 - **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지, GPU 사양에 맞춰 고릅니다 - **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·위치·줄 수 - **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
- **용어집** — 게임 고유명사를 원하는 번역으로 고정. 학습 없이 즉시 적용 - **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
- **게임별 기본 용어집 내장** — 롤·발로란트·옵치2·마크·FPS공통·방송용어 **304개**
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다 - **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
- **완전 로컬** — 음성이 외부로 나가지 않습니다 - **완전 로컬** — 음성이 외부로 나가지 않습니다
--- ---
## 게임과 같이 쓰기 (GPU 절약)
게임이 먼저입니다. `설정` 화면의 **저부하 모드**(기본 켜짐)가 이렇게 동작합니다.
| 항목 | 기본값 | 하는 일 |
|---|---|---|
| 연산 정밀도 | int8 | VRAM 사용량과 연산량을 줄입니다 |
| VRAM 상한 | 35% | 게임이 쓸 VRAM을 먼저 확보합니다 |
| GPU 양보 시간 | 60ms | 문장 하나 처리 후 GPU를 잠깐 놓습니다 |
| 유휴 시 모델 해제 | 300초 | 조용하면 모델을 내려 VRAM을 통째로 반납합니다 |
| 중간 결과 | 꺼짐 | 같은 오디오를 두 번 인식하지 않습니다 |
8GB GPU 기준으로 자막이 약 2.8GB만 쓰고 나머지 5GB 이상을 게임에 남깁니다.
게임이 여전히 버벅이면 VRAM 상한을 낮추거나 모델 티어를 `1. 번개`로 내리세요.
![설정 화면](docs/images/settings.png)
## 자막 위치 잡기
`자막` 화면에서 **모니터를 고르고 네모칸을 누르면** 그 자리에 붙습니다.
디스코드 오버레이와 같은 방식입니다. 미세 조정은 자막을 직접 드래그하면 되고,
그러면 그 위치를 그대로 기억합니다.
![자막 화면](docs/images/subtitle.png)
| 단축키 | 동작 |
|---|---|
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 |
| `Ctrl+Alt+D` | 번역 시작 / 정지 |
전역 단축키라 **게임 창이 떠 있어도 동작합니다** (Windows 전용). `설정`에서 바꿀 수 있습니다.
---
## 설치 ## 설치
### 1. 사전 준비 ### 1. 사전 준비
@@ -74,22 +112,28 @@ powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
### 5. 실행 ### 5. 실행
```powershell ```powershell
hearo livesub
``` ```
또는 `python -m hearo` 또는 `python -m livesub`
--- ---
## 처음 쓸 때 ## 처음 쓸 때
1. **모델** 화면에서 티어를 고릅니다. VRAM 8GB면 `3. 균형`, 10GB 이상이면 `4. 정밀` 1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다 2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다 3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
4. `번역 시작` 4. **자막** 화면에서 모니터와 위치를 정합니다
5. `번역 시작` — 또는 게임 중에 `Ctrl+Alt+D`
모델 티어 기본값은 `2. 신속`입니다. 게임과 같이 돌리기 좋은 지점이라 그렇게 잡았고,
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름). 모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 `%APPDATA%\Hearo\models` 입니다. 저장 위치는 `%APPDATA%\LiveSub\models` 입니다.
![모델 화면](docs/images/models.png)
--- ---
@@ -98,13 +142,11 @@ hearo
| # | 이름 | VRAM | 지연 | 설명 | | # | 이름 | VRAM | 지연 | 설명 |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | 번개 | 2GB | 0.6초 | 속도 최우선. 저사양 GPU | | 1 | 번개 | 2GB | 0.6초 | 속도 최우선. 저사양 GPU |
| 2 | 신속 | 4GB | 0.9초 | 인식률은 최상급, 번역만 경량 | | 2 | **신속** | 4GB | 0.9초 | **기본값.** 인식률은 최상급, 번역만 경량 |
| 3 | **균형** | 6GB | 1.2초 | 기본값. 8GB GPU에 가장 무난 | | 3 | 균형 | 6GB | 1.2초 | 게임을 안 켜고 볼 때 무난 |
| 4 | **정밀** | 10GB | 2.0초 | **추천.** 현 시점 최고 번역 품질 | | 4 | **정밀** | 10GB | 2.0초 | **추천.** 현 시점 최고 번역 품질 |
| 5 | 극한 | 16GB | 3.0초 | 추가학습(LoRA)에 최적 | | 5 | 극한 | 16GB | 3.0초 | 추가학습(LoRA)에 최적 |
![모델 화면](docs/images/models.png)
어떤 모델을 왜 골랐는지는 [docs/MODELS.md](docs/MODELS.md)에 정리했습니다. 어떤 모델을 왜 골랐는지는 [docs/MODELS.md](docs/MODELS.md)에 정리했습니다.
--- ---
@@ -113,7 +155,23 @@ hearo
게임 고유명사가 이상하게 번역될 때 씁니다. **학습이 필요 없고 즉시 적용됩니다.** 게임 고유명사가 이상하게 번역될 때 씁니다. **학습이 필요 없고 즉시 적용됩니다.**
`용어집` 화면에서 직접 입력하거나 CSV로 가져오세요. ### 기본 제공 용어집 (체크만 하면 끝)
| 팩 | 개수 | 내용 |
|---|---|---|
| 리그 오브 레전드 | 74 | 바론·갱·억제기·한타·오브젝트 콜 |
| 발로란트 | 59 | 스파이크·설치·해체·이코·리테이크 |
| 오버워치 2 | 49 | 궁 게이지·거점·화물·역할군 |
| FPS 공통 | 46 | 배그·CS·에이펙스에서 두루 쓰는 교전/파티 콜 |
| 마인크래프트 | 38 | 블록·몹·차원 이름 (한국어판 공식 번역어) |
| 방송·스트리밍 | 38 | 후원·구독·클립·메타 등 방송 말투 |
![용어집 화면](docs/images/glossary.png)
### 직접 추가하기
`용어집` 화면 표에 입력하거나 CSV로 가져오세요.
**같은 단어를 직접 등록하면 기본 팩보다 항상 우선합니다.**
```csv ```csv
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모 원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
@@ -121,8 +179,6 @@ Nexus,넥서스,Nexus,ネクサス,基地,LoL
baron,바론,Baron,バロン,男爵,LoL baron,바론,Baron,バロン,男爵,LoL
``` ```
게임 하나당 200~500개만 등록해도 체감 품질이 크게 달라집니다.
## 추가학습 ## 추가학습
말투나 문장 구조까지 바꾸고 싶다면 LoRA로 학습시킬 수 있습니다. 말투나 문장 구조까지 바꾸고 싶다면 LoRA로 학습시킬 수 있습니다.
@@ -142,11 +198,15 @@ python scripts\finetune_mt.py --data data\game.jsonl --tier ultimate --output .\
| 동작 | 결과 | | 동작 | 결과 |
|---|---| |---|---|
| 드래그 | 위치 이동 | | `Ctrl+Alt+S` | 자막 켜기 / 끄기 (게임 중에도 동작) |
| 드래그 | 위치 이동 (자유 배치로 전환) |
| 우하단 모서리 드래그 | 크기 조절 | | 우하단 모서리 드래그 | 크기 조절 |
| 마우스 휠 | 글자 크기 | | 마우스 휠 | 글자 크기 |
| 우클릭 | 잠금 / 클릭 통과 / 항상 위에 / 숨기기 | | 우클릭 | 잠금 / 클릭 통과 / 항상 위에 / 숨기기 |
클릭 통과는 기본으로 켜져 있어, 자막 위를 클릭해도 게임으로 전달됩니다.
위치를 옮기려면 `자막` 화면에서 클릭 통과를 잠깐 끄세요.
전체화면 게임에서 자막이 안 보이면 게임을 **테두리 없는 창 모드**로 바꾸세요. 전체화면 게임에서 자막이 안 보이면 게임을 **테두리 없는 창 모드**로 바꾸세요.
독점 전체화면(exclusive fullscreen)에서는 어떤 오버레이도 표시되지 않습니다. 독점 전체화면(exclusive fullscreen)에서는 어떤 오버레이도 표시되지 않습니다.

View File

@@ -4,7 +4,7 @@
``` ```
[캡처 스레드] [분할 스레드] [추론 스레드] [GUI 스레드] [캡처 스레드] [분할 스레드] [추론 스레드] [GUI 스레드]
hearo_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 → 자막 오버레이 livesub_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 → 자막 오버레이
또는 WASAPI 발화 단위로 절단 용어집 적용 컨트롤 창 로그 또는 WASAPI 발화 단위로 절단 용어집 적용 컨트롤 창 로그
↓ ↓ ↓ ↑ ↓ ↓ ↓ ↑
오디오 큐 구간 큐(최대 32) TranslationLine Qt Signal 오디오 큐 구간 큐(최대 32) TranslationLine Qt Signal
@@ -18,7 +18,7 @@ hearo_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 →
## 패키지 ## 패키지
``` ```
src/hearo/ src/livesub/
constants.py 전역 상수, 언어 표, 경로 constants.py 전역 상수, 언어 표, 경로
config.py 설정 dataclass + JSON 영속화 config.py 설정 dataclass + JSON 영속화
app.py 진입점 app.py 진입점
@@ -31,11 +31,12 @@ src/hearo/
segmenter.py 적응형 VAD로 발화 구간 절단 segmenter.py 적응형 VAD로 발화 구간 절단
models/ models/
tiers.py 5단계 티어 정의 tiers.py 5단계 티어 + 프롬프트 형식 정의
asr.py faster-whisper 래퍼 asr.py faster-whisper 래퍼
translator.py CTranslate2 / Transformers 두 백엔드 translator.py CTranslate2 / Transformers 두 백엔드
glossary.py 용어집 (플레이스홀더 보호 + 프롬프트 주입) glossary.py 용어집 (플레이스홀더 보호 + 프롬프트 주입)
manager.py 티어별 모델 쌍 로드·해제, GPU 탐지 packs.py 게임별 기본 용어집 팩 로드·병합
manager.py 티어별 모델 쌍 로드·해제, GPU 탐지, VRAM 상한
core/ core/
engine.py 파이프라인 오케스트레이션 (Qt 비의존) engine.py 파이프라인 오케스트레이션 (Qt 비의존)
@@ -44,9 +45,13 @@ src/hearo/
ui/ ui/
theme.py 디자인 토큰 + QSS theme.py 디자인 토큰 + QSS
overlay.py 자막 창 + 공용 렌더링 함수 overlay.py 자막 창 + 공용 렌더링 함수
placement.py 모니터 목록 + 9분할 배치 계산 (Qt 비의존)
hotkeys.py 전역 단축키 (Windows RegisterHotKey)
main_window.py 사이드바 + 페이지 스택 main_window.py 사이드바 + 페이지 스택
pages/ 홈 / 모델 / 자막 / 용어집 / 설정 pages/ 홈 / 모델 / 자막 / 용어집 / 설정
widgets/ Card, StatusPill, LevelMeter widgets/ Card, StatusPill, LevelMeter, AnchorGrid
resources/glossaries/ 게임별 기본 용어집 JSON (롤·발로란트·옵치2·마크 등)
native/process_loopback/ WASAPI process loopback C++ 보조 프로그램 native/process_loopback/ WASAPI process loopback C++ 보조 프로그램
scripts/finetune_mt.py LoRA 추가학습 scripts/finetune_mt.py LoRA 추가학습
@@ -63,12 +68,34 @@ WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_L
보조 프로그램이 없으면 `capture_capabilities()` 가 이를 알려주고 장치 루프백으로 보조 프로그램이 없으면 `capture_capabilities()` 가 이를 알려주고 장치 루프백으로
자동 폴백한다. 빌드 없이도 프로그램은 동작한다. 자동 폴백한다. 빌드 없이도 프로그램은 동작한다.
### 게임이 먼저다
이 프로그램은 게임 위에 얹혀 도는 것이 기본 용도라, 자막 품질보다 게임 프레임을
지키는 쪽이 대체로 더 중요하다. 저부하 모드는 네 방향에서 GPU를 양보한다.
1. **연산 정밀도 강등** (`ModelManager.effective_*_spec`) — float16 → int8.
티어 정의 자체를 건드리지 않고 파생 사양만 바꾸므로, 모드를 끄면 즉시 원복된다.
2. **VRAM 상한** (`apply_vram_limit`) — 모델을 올리기 *전에* 걸어야 실제로 먹는다.
게임과 동시에 돌 때 프레임을 죽이는 주범은 연산 시간이 아니라 VRAM 고갈이다.
게임이 쓸 메모리를 우리가 먹으면 텍스처 스트리밍이 시스템 메모리로 밀려난다.
3. **추론 후 양보** — 문장 하나를 처리하고 수십 ms 쉰다. 연속 발화 때 GPU를
독점하지 않기 위해서다.
4. **유휴 언로드** — 조용한 구간이 길어지면 모델을 통째로 내린다. 그래서
`_infer_loop` 은 모델 참조를 캐시하지 않고 매번 `models.recognizer()` 를 부른다.
### 중간 결과는 번역하지 않는다 ### 중간 결과는 번역하지 않는다
말하는 도중에도 인식 결과를 흐리게 보여주면 체감 지연이 크게 줄어든다. 하지만 말하는 도중에도 인식 결과를 흐리게 보여주면 체감 지연이 크게 줄어든다. 하지만
매번 번역까지 돌리면 GPU가 몇 배로 바빠지고, 문장이 완성되기 전 번역은 어차피 매번 번역까지 돌리면 GPU가 몇 배로 바빠지고, 문장이 완성되기 전 번역은 어차피
틀린다. 그래서 **중간 결과는 원문만, 확정 문장만 번역**한다. 틀린다. 그래서 **중간 결과는 원문만, 확정 문장만 번역**한다.
### 배치 계산을 Qt에서 떼어냈다
`placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에
모니터가 없는 CI 환경에서도 "두 번째 모니터 하단 중앙", "음수 좌표 모니터",
"화면보다 큰 자막 창" 같은 경우를 전부 테스트할 수 있다 (`tests/test_placement.py`).
자막이 화면 밖으로 나가 영영 안 보이는 사고는 여기서 막는다.
### UI 코드와 렌더링 코드 분리 ### UI 코드와 렌더링 코드 분리
자막 설정 미리보기와 실제 오버레이가 `paint_subtitle()` 하나를 공유한다. 자막 설정 미리보기와 실제 오버레이가 `paint_subtitle()` 하나를 공유한다.

View File

@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다. 1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬. 2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면 3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
`%APPDATA%/Hearo/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. `%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다. **틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로. 4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -1,22 +1,22 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(hearo_capture LANGUAGES CXX) project(livesub_capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(hearo_capture main.cpp) add_executable(livesub_capture main.cpp)
if(MSVC) if(MSVC)
target_compile_options(hearo_capture PRIVATE /W3 /permissive- /EHsc) target_compile_options(livesub_capture PRIVATE /W3 /permissive- /EHsc)
target_compile_definitions(hearo_capture PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX) target_compile_definitions(livesub_capture PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX)
endif() endif()
target_link_libraries(hearo_capture PRIVATE ole32 mmdevapi) target_link_libraries(livesub_capture PRIVATE ole32 mmdevapi)
# 빌드 결과를 파이썬 패키지가 찾는 위치로 복사한다. # 빌드 결과를 파이썬 패키지가 찾는 위치로 복사한다.
add_custom_command(TARGET hearo_capture POST_BUILD add_custom_command(TARGET livesub_capture POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory COMMAND ${CMAKE_COMMAND} -E make_directory
"${CMAKE_SOURCE_DIR}/../../src/hearo/resources/bin" "${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin"
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:hearo_capture>" "$<TARGET_FILE:livesub_capture>"
"${CMAKE_SOURCE_DIR}/../../src/hearo/resources/bin/") "${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin/")

View File

@@ -13,7 +13,7 @@ $build = Join-Path $here "build"
cmake -S $here -B $build -A x64 cmake -S $here -B $build -A x64
cmake --build $build --config Release cmake --build $build --config Release
$out = Join-Path $here "..\..\src\hearo\resources\bin\hearo_capture.exe" $out = Join-Path $here "..\..\src\livesub\resources\bin\livesub_capture.exe"
if (Test-Path $out) { if (Test-Path $out) {
Write-Host "빌드 완료: $out" -ForegroundColor Green Write-Host "빌드 완료: $out" -ForegroundColor Green
} else { } else {

View File

@@ -1,8 +1,8 @@
// hearo_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만 // livesub_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만
// 뽑아내 stdout 으로 흘려보내는 보조 프로그램. // 뽑아내 stdout 으로 흘려보내는 보조 프로그램.
// //
// 출력 포맷: 16000Hz / mono / 32-bit float, 헤더 없는 생 PCM. // 출력 포맷: 16000Hz / mono / 32-bit float, 헤더 없는 생 PCM.
// 사용법: hearo_capture.exe --pid 1234 [--exclude] // 사용법: livesub_capture.exe --pid 1234 [--exclude]
// --exclude 를 주면 해당 프로세스 트리를 "제외한" 나머지 소리를 받는다. // --exclude 를 주면 해당 프로세스 트리를 "제외한" 나머지 소리를 받는다.
// //
// Windows 10 2004(build 19041) 이상에서 동작하는 WASAPI process loopback API를 // Windows 10 2004(build 19041) 이상에서 동작하는 WASAPI process loopback API를
@@ -77,7 +77,7 @@ private:
}; };
void Fail(const char* what, HRESULT hr) { void Fail(const char* what, HRESULT hr) {
std::fprintf(stderr, "[hearo_capture] %s (hr=0x%08lX)\n", what, static_cast<unsigned long>(hr)); std::fprintf(stderr, "[livesub_capture] %s (hr=0x%08lX)\n", what, static_cast<unsigned long>(hr));
} }
int Run(DWORD pid, bool exclude) { int Run(DWORD pid, bool exclude) {
@@ -156,7 +156,7 @@ int Run(DWORD pid, bool exclude) {
return 4; return 4;
} }
std::fprintf(stderr, "[hearo_capture] pid=%lu 캡처 시작 (%dHz mono f32)\n", std::fprintf(stderr, "[livesub_capture] pid=%lu 캡처 시작 (%dHz mono f32)\n",
static_cast<unsigned long>(pid), kSampleRate); static_cast<unsigned long>(pid), kSampleRate);
std::fflush(stderr); std::fflush(stderr);
@@ -211,7 +211,7 @@ int main(int argc, char** argv) {
} }
} }
if (pid == 0) { if (pid == 0) {
std::fprintf(stderr, "사용법: hearo_capture.exe --pid <PID> [--exclude]\n"); std::fprintf(stderr, "사용법: livesub_capture.exe --pid <PID> [--exclude]\n");
return 64; return 64;
} }

View File

@@ -3,9 +3,9 @@ requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "hearo" name = "livesub"
version = "0.1.0" version = "0.2.0"
description = "프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구" description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = { text = "MIT" } license = { text = "MIT" }
@@ -46,13 +46,13 @@ finetune = [
dev = ["pytest>=8.0", "ruff>=0.6"] dev = ["pytest>=8.0", "ruff>=0.6"]
[project.scripts] [project.scripts]
hearo = "hearo.app:main" livesub = "livesub.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
hearo = ["resources/bin/*"] livesub = ["resources/bin/*", "resources/glossaries/*.json"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

View File

@@ -20,14 +20,14 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from hearo.constants import LANGUAGES, models_dir # noqa: E402 from livesub.constants import LANGUAGES, models_dir # noqa: E402
from hearo.models.tiers import ( # noqa: E402 from livesub.models.tiers import ( # noqa: E402
MTBackend, MTBackend,
PromptStyle, PromptStyle,
get_tier, get_tier,
ordered_tiers, ordered_tiers,
) )
from hearo.models.translator import build_seedx_prompt # noqa: E402 from livesub.models.translator import build_seedx_prompt # noqa: E402
#: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름 #: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름
LORA_TARGETS = ["q_proj", "v_proj"] LORA_TARGETS = ["q_proj", "v_proj"]

View File

@@ -1,176 +0,0 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
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

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

View File

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

View File

@@ -11,7 +11,7 @@ from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False) -> None: def setup_logging(verbose: bool = False) -> None:
log_file = user_data_dir() / "hearo.log" log_file = user_data_dir() / "livesub.log"
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
handlers.append(logging.FileHandler(log_file, encoding="utf-8")) handlers.append(logging.FileHandler(log_file, encoding="utf-8"))

View File

@@ -59,7 +59,7 @@ def create_capture(config: AudioConfig) -> CaptureBackend:
return ProcessLoopbackCapture(config.target_pid) return ProcessLoopbackCapture(config.target_pid)
if backend == "process": if backend == "process":
raise CaptureError( raise CaptureError(
"프로그램별 캡처를 쓰려면 hearo_capture.exe 가 필요합니다. " "프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요." "native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
) )
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.") log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")

View File

@@ -1,6 +1,6 @@
"""프로그램별 오디오 캡처 (Windows WASAPI process loopback). """프로그램별 오디오 캡처 (Windows WASAPI process loopback).
`native/process_loopback` 에서 빌드한 `hearo_capture.exe` 를 자식 프로세스로 띄우고 `native/process_loopback` 에서 빌드한 `livesub_capture.exe` 를 자식 프로세스로 띄우고
stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다. stdout 으로 흘러나오는 16kHz mono float32 PCM 을 그대로 읽는다.
보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는 보조 프로그램이 없으면 `available()` 이 False 를 돌려주고, 상위 레이어는
@@ -21,7 +21,7 @@ from .base import AudioSource, CaptureBackend, CaptureError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
HELPER_NAME = "hearo_capture.exe" HELPER_NAME = "livesub_capture.exe"
READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량 READ_BYTES = FRAME_SAMPLES * 4 * 5 # 100ms 분량

View File

@@ -39,14 +39,22 @@ class SubtitleStyle:
class OverlayConfig: class OverlayConfig:
"""자막 창(오버레이) 위치/동작.""" """자막 창(오버레이) 위치/동작."""
x: int = -1 # -1 = 화면 하단 중앙 자동 배치 x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
y: int = -1 y: int = -1
width: int = 1100 width: int = 1100
height: int = 200 height: int = 200
always_on_top: bool = True always_on_top: bool = True
click_through: bool = False #: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True
locked: bool = False locked: bool = False
#: 자막 표시 on/off (단축키로 토글)
visible: bool = True visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터)
screen_index: int = 0
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
anchor: str = "bottom-center"
#: 화면 가장자리에서 띄울 간격
margin: int = 60
@dataclass @dataclass
@@ -79,6 +87,40 @@ class GlossaryConfig:
enabled: bool = True enabled: bool = True
path: str = "" # 비어있으면 user_data_dir()/glossary.json path: str = "" # 비어있으면 user_data_dir()/glossary.json
case_sensitive: bool = False case_sensitive: bool = False
#: 함께 불러온 기본 제공 용어집 팩 키 목록 (presets/games 참고)
enabled_packs: list[str] = field(default_factory=list)
@dataclass
class PerformanceConfig:
"""게임과 동시에 돌릴 때 GPU를 얼마나 양보할지.
이 프로그램은 게임 위에 얹혀 돌아가는 것이 기본 용도라, 자막 품질보다
게임 프레임을 지키는 쪽이 대체로 더 중요하다.
"""
#: 저부하 모드. 켜면 아래 항목들이 자동으로 보수적인 값으로 적용된다.
game_mode: bool = True
#: 프로세스가 쓸 수 있는 VRAM 상한 비율 (0이면 제한 없음).
#: 게임이 VRAM 부족으로 버벅이는 것을 막는다.
vram_limit_ratio: float = 0.35
#: 한 번 추론하고 나서 GPU를 놓아주는 시간. 게임 프레임 확보용.
yield_ms: int = 60
#: 이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납한다. 0이면 끔.
idle_unload_s: int = 300
#: 저부하 모드에서 연산 정밀도를 int8로 낮출지
force_int8: bool = True
@dataclass
class HotkeyConfig:
"""전역 단축키. 게임 창이 떠 있어도 동작한다."""
enabled: bool = True
#: 자막 표시 on/off
toggle_subtitle: str = "Ctrl+Alt+S"
#: 번역 엔진 시작/정지
toggle_engine: str = "Ctrl+Alt+D"
@dataclass @dataclass
@@ -88,6 +130,8 @@ class AppConfig:
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle) subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
overlay: OverlayConfig = field(default_factory=OverlayConfig) overlay: OverlayConfig = field(default_factory=OverlayConfig)
glossary: GlossaryConfig = field(default_factory=GlossaryConfig) glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
performance: PerformanceConfig = field(default_factory=PerformanceConfig)
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
theme: str = "dark" # dark | light theme: str = "dark" # dark | light
start_minimized: bool = False start_minimized: bool = False
log_transcripts: bool = True log_transcripts: bool = True

View File

@@ -4,10 +4,10 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
APP_NAME = "Hearo" APP_NAME = "LiveSub"
APP_NAME_KO = "히어로" APP_NAME_KO = "라이브섭"
APP_SLOGAN = "듣고, 바로 이해하다" APP_SLOGAN = "게임 소리를 실시간 자막으로"
APP_VERSION = "0.1.0" APP_VERSION = "0.2.0"
ORG_NAME = "tkrmagid" ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개) # 지원 언어 (1차: 4개)
@@ -29,7 +29,10 @@ LANGUAGE_CODES = tuple(LANGUAGES.keys())
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만, # 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다. # config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
DEFAULT_TIER = "balance" #
# 게임과 동시에 돌리는 것이 주 용도라 기본값을 '균형'(6GB)에서 '신속'(4GB)으로
# 낮췄다. 인식 품질은 그대로 최상급이고 번역만 경량이라 체감 손해가 가장 적다.
DEFAULT_TIER = "swift"
# 오디오 파이프라인 규격 # 오디오 파이프라인 규격
SAMPLE_RATE = 16_000 SAMPLE_RATE = 16_000

View File

@@ -26,6 +26,8 @@ from ..audio import (
from ..config import AppConfig from ..config import AppConfig
from ..constants import LANGUAGE_CODES, user_data_dir from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary
from .events import EngineState, EngineStatus, TranslationLine from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -45,7 +47,7 @@ class TranslationEngine:
self._on_line = on_line self._on_line = on_line
self._on_status = on_status self._on_status = on_status
self.models = ModelManager(config.models) self.models = ModelManager(config.models, low_power=config.performance.game_mode)
self.glossary = self._load_glossary() self.glossary = self._load_glossary()
self._capture: CaptureBackend | None = None self._capture: CaptureBackend | None = None
@@ -56,6 +58,9 @@ class TranslationEngine:
self._level = 0.0 self._level = 0.0
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다. #: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
self._last_final_text = "" self._last_final_text = ""
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
self._last_activity = time.monotonic()
self._models_unloaded = False
# --- 수명주기 ------------------------------------------------------- # --- 수명주기 -------------------------------------------------------
@property @property
@@ -101,6 +106,10 @@ class TranslationEngine:
# --- 내부 스레드 ---------------------------------------------------- # --- 내부 스레드 ----------------------------------------------------
def _bootstrap(self) -> None: def _bootstrap(self) -> None:
try: try:
perf = self.config.performance
if perf.game_mode and self.config.models.device == "cuda":
# 모델을 올리기 *전에* 걸어야 상한이 실제로 먹는다.
apply_vram_limit(perf.vram_limit_ratio, self.config.models.device_index)
if self.config.models.preload_on_start: if self.config.models.preload_on_start:
self.models.preload( self.models.preload(
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct) lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
@@ -124,12 +133,15 @@ class TranslationEngine:
def _segment_loop(self) -> None: def _segment_loop(self) -> None:
cfg = self.config.audio cfg = self.config.audio
# 중간 결과는 같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 된다.
# 게임과 같이 돌릴 때는 그만한 값어치가 없으므로 저부하 모드에서는 끈다.
partial_ms = 0 if self.config.performance.game_mode else cfg.partial_interval_ms
segmenter = Segmenter( segmenter = Segmenter(
SegmenterConfig( SegmenterConfig(
silence_ms=cfg.silence_ms, silence_ms=cfg.silence_ms,
min_segment_ms=cfg.min_segment_ms, min_segment_ms=cfg.min_segment_ms,
max_segment_ms=cfg.max_segment_ms, max_segment_ms=cfg.max_segment_ms,
partial_interval_ms=cfg.partial_interval_ms, partial_interval_ms=partial_ms,
vad_aggressiveness=cfg.vad_aggressiveness, vad_aggressiveness=cfg.vad_aggressiveness,
) )
) )
@@ -169,25 +181,56 @@ class TranslationEngine:
pass pass
def _infer_loop(self) -> None: def _infer_loop(self) -> None:
recognizer = self.models.recognizer()
translator = self.models.translator()
mcfg = self.config.models mcfg = self.config.models
perf = self.config.performance
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
segment = self._segments.get(timeout=0.3) segment = self._segments.get(timeout=0.3)
except queue.Empty: except queue.Empty:
self._maybe_unload_idle()
continue continue
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다. # 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
if not segment.is_final and not self._segments.empty(): if not segment.is_final and not self._segments.empty():
continue continue
# 모델 참조를 캐시하지 않는다. 유휴 언로드 뒤에는 새로 올려야 하고,
# 캐시된 참조를 쓰면 이미 내려간 모델을 붙잡고 있게 된다.
try: try:
self._process(segment, recognizer, translator, mcfg) self._process(
segment, self.models.recognizer(), self.models.translator(), mcfg
)
self._last_activity = time.monotonic()
self._models_unloaded = False
except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다 except Exception as exc: # noqa: BLE001 - 한 문장 실패로 엔진이 죽으면 안 된다
log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG)) log.warning("구간 처리 실패: %s", exc, exc_info=log.isEnabledFor(logging.DEBUG))
# 게임에 GPU를 잠깐 돌려준다. 연속 발화 때 우리가 GPU를 독점하면
# 프레임이 눈에 띄게 끊긴다.
if perf.game_mode and perf.yield_ms > 0:
self._stop.wait(perf.yield_ms / 1000)
def _maybe_unload_idle(self) -> None:
"""한동안 말소리가 없으면 모델을 내려 VRAM을 통째로 돌려준다.
게임 중에 조용한 구간(로딩·메뉴·솔로 플레이)이 길게 이어지는데
그동안 수 GB를 붙잡고 있을 이유가 없다. 다음 발화가 오면 자동으로
다시 올라간다 — 그때 한 번 로딩 지연이 생기는 것이 트레이드오프다.
"""
perf = self.config.performance
if not perf.game_mode or perf.idle_unload_s <= 0 or self._models_unloaded:
return
if time.monotonic() - self._last_activity < perf.idle_unload_s:
return
if not self.models.ready:
self._models_unloaded = True
return
log.info("%d초간 조용해 모델을 내립니다 (VRAM 반납)", perf.idle_unload_s)
self.models.unload()
self._models_unloaded = True
self._emit_status(EngineState.RUNNING, "듣는 중 · 대기 (VRAM 반납됨)")
def _process(self, segment: Segment, recognizer, translator, mcfg) -> None: def _process(self, segment: Segment, recognizer, translator, mcfg) -> None:
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
@@ -236,9 +279,16 @@ class TranslationEngine:
# --- 유틸 ----------------------------------------------------------- # --- 유틸 -----------------------------------------------------------
def _load_glossary(self) -> Glossary: def _load_glossary(self) -> Glossary:
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
cfg = self.config.glossary cfg = self.config.glossary
path = cfg.path or str(user_data_dir() / "glossary.json") path = cfg.path or str(user_data_dir() / "glossary.json")
return Glossary.load(path, cfg.case_sensitive) user = Glossary.load(path, cfg.case_sensitive)
merged = build_glossary(user, cfg.enabled_packs, cfg.case_sensitive)
log.info(
"용어집 %d개 (사용자 %d + 팩 %s)",
len(merged), len(user), ", ".join(cfg.enabled_packs) or "없음",
)
return merged
def _drain_segments(self) -> None: def _drain_segments(self) -> None:
while not self._segments.empty(): while not self._segments.empty():

View File

@@ -0,0 +1,53 @@
{
"key": "fps-common",
"name": "FPS 공통 (배그·CS·에이펙스)",
"description": "총기·교전·파티 콜처럼 슈터 장르 전반에서 공통으로 쓰는 말.",
"entries": [
{"source": "enemy", "targets": {"ko": "적", "ja": "敵", "zh": "敌人"}},
{"source": "enemy spotted", "targets": {"ko": "적 발견", "ja": "敵発見", "zh": "发现敌人"}},
{"source": "contact", "targets": {"ko": "교전", "ja": "接敵", "zh": "接敌"}},
{"source": "reloading", "targets": {"ko": "재장전 중", "ja": "リロード中", "zh": "换弹中"}},
{"source": "cover me", "targets": {"ko": "엄호해줘", "ja": "援護して", "zh": "掩护我"}},
{"source": "covering", "targets": {"ko": "엄호 중", "ja": "援護中", "zh": "掩护中"}},
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}},
{"source": "fall back", "targets": {"ko": "후퇴", "ja": "後退", "zh": "撤退"}},
{"source": "hold", "targets": {"ko": "대기", "ja": "ホールド", "zh": "守住"}},
{"source": "knocked", "targets": {"ko": "기절", "ja": "ダウン", "zh": "被击倒"}},
{"source": "down", "targets": {"ko": "다운", "ja": "ダウン", "zh": "倒地"}},
{"source": "revive", "targets": {"ko": "부활", "ja": "リヴァイヴ", "zh": "复活"}},
{"source": "res", "targets": {"ko": "살려줘", "ja": "蘇生して", "zh": "救我"}},
{"source": "heal up", "targets": {"ko": "회복하자", "ja": "回復しよう", "zh": "回血"}},
{"source": "loot", "targets": {"ko": "파밍", "ja": "ルート", "zh": "搜物资"}},
{"source": "looting", "targets": {"ko": "파밍 중", "ja": "ルート中", "zh": "搜物资中"}},
{"source": "third party", "targets": {"ko": "어부", "ja": "漁夫", "zh": "第三方"}},
{"source": "zone", "targets": {"ko": "자기장", "ja": "安全地帯", "zh": "毒圈"}},
{"source": "circle", "targets": {"ko": "자기장", "ja": "サークル", "zh": "圈"}},
{"source": "storm", "targets": {"ko": "폭풍", "ja": "ストーム", "zh": "风暴"}},
{"source": "high ground", "targets": {"ko": "고지", "ja": "高所", "zh": "高地"}},
{"source": "flank", "targets": {"ko": "측면", "ja": "側面", "zh": "侧翼"}},
{"source": "behind us", "targets": {"ko": "뒤에 있다", "ja": "後ろにいる", "zh": "背后有人"}},
{"source": "on me", "targets": {"ko": "나한테 붙어", "ja": "ついてきて", "zh": "跟着我"}},
{"source": "grenade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
{"source": "nade", "targets": {"ko": "수류탄", "ja": "グレネード", "zh": "手雷"}},
{"source": "frag", "targets": {"ko": "파편탄", "ja": "フラグ", "zh": "破片雷"}},
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟雾弹"}},
{"source": "flashbang", "targets": {"ko": "섬광탄", "ja": "フラッシュ", "zh": "闪光弹"}},
{"source": "scope", "targets": {"ko": "조준경", "ja": "スコープ", "zh": "瞄准镜"}},
{"source": "ADS", "targets": {"ko": "정조준", "ja": "ADS", "zh": "开镜"}},
{"source": "recoil", "targets": {"ko": "반동", "ja": "リコイル", "zh": "后坐力"}},
{"source": "spray control", "targets": {"ko": "반동 제어", "ja": "リコイル制御", "zh": "压枪"}},
{"source": "aim", "targets": {"ko": "에임", "ja": "エイム", "zh": "枪法"}},
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
{"source": "wall bang", "targets": {"ko": "관통샷", "ja": "ウォールバン", "zh": "穿墙"}},
{"source": "prefire", "targets": {"ko": "선샷", "ja": "プリファイア", "zh": "预瞄"}},
{"source": "camping", "targets": {"ko": "캠핑", "ja": "キャンプ", "zh": "蹲点"}},
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
{"source": "kill streak", "targets": {"ko": "연속 킬", "ja": "キルストリーク", "zh": "连杀"}},
{"source": "ammo", "targets": {"ko": "탄약", "ja": "弾薬", "zh": "子弹"}},
{"source": "out of ammo", "targets": {"ko": "탄 없음", "ja": "弾切れ", "zh": "没子弹"}},
{"source": "backup", "targets": {"ko": "지원", "ja": "バックアップ", "zh": "支援"}},
{"source": "good game", "targets": {"ko": "잘 했다", "ja": "GG", "zh": "打得好"}},
{"source": "nice shot", "targets": {"ko": "잘 쐈다", "ja": "ナイスショット", "zh": "好枪"}},
{"source": "my bad", "targets": {"ko": "내 실수", "ja": "ごめん", "zh": "我的错"}}
]
}

View File

@@ -0,0 +1,81 @@
{
"key": "lol",
"name": "리그 오브 레전드",
"description": "라인·오브젝트·교전 콜, 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
"entries": [
{"source": "Nexus", "targets": {"ko": "넥서스", "ja": "ネクサス", "zh": "基地"}},
{"source": "inhibitor", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
{"source": "inhib", "targets": {"ko": "억제기", "ja": "抑制器", "zh": "水晶"}},
{"source": "turret", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
{"source": "tower", "targets": {"ko": "포탑", "ja": "タワー", "zh": "防御塔"}},
{"source": "baron", "targets": {"ko": "바론", "ja": "バロン", "zh": "大龙"}},
{"source": "Baron Nashor", "targets": {"ko": "내셔 남작", "ja": "バロン・ナッシャー", "zh": "纳什男爵"}},
{"source": "dragon", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
{"source": "drake", "targets": {"ko": "드래곤", "ja": "ドラゴン", "zh": "小龙"}},
{"source": "elder", "targets": {"ko": "장로 드래곤", "ja": "エルダードラゴン", "zh": "远古龙"}},
{"source": "herald", "targets": {"ko": "전령", "ja": "ヘラルド", "zh": "峡谷先锋"}},
{"source": "Rift Herald", "targets": {"ko": "협곡의 전령", "ja": "リフトヘラルド", "zh": "峡谷先锋"}},
{"source": "grubs", "targets": {"ko": "유충", "ja": "虫", "zh": "虚空幼虫"}},
{"source": "gank", "targets": {"ko": "갱", "ja": "ガンク", "zh": "抓人"}},
{"source": "ganking", "targets": {"ko": "갱킹", "ja": "ガンク", "zh": "抓人"}},
{"source": "counter gank", "targets": {"ko": "역갱", "ja": "カウンターガンク", "zh": "反蹲"}},
{"source": "roam", "targets": {"ko": "로밍", "ja": "ローム", "zh": "游走"}},
{"source": "jungle", "targets": {"ko": "정글", "ja": "ジャングル", "zh": "打野"}},
{"source": "jungler", "targets": {"ko": "정글러", "ja": "ジャングラー", "zh": "打野"}},
{"source": "top lane", "targets": {"ko": "탑", "ja": "トップ", "zh": "上路"}},
{"source": "mid lane", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
{"source": "bot lane", "targets": {"ko": "바텀", "ja": "ボット", "zh": "下路"}},
{"source": "support", "targets": {"ko": "서폿", "ja": "サポート", "zh": "辅助"}},
{"source": "ADC", "targets": {"ko": "원딜", "ja": "ADC", "zh": "ADC"}},
{"source": "minion", "targets": {"ko": "미니언", "ja": "ミニオン", "zh": "小兵"}},
{"source": "wave", "targets": {"ko": "웨이브", "ja": "ウェーブ", "zh": "兵线"}},
{"source": "creep score", "targets": {"ko": "CS", "ja": "CS", "zh": "补刀"}},
{"source": "last hit", "targets": {"ko": "막타", "ja": "ラストヒット", "zh": "补刀"}},
{"source": "freeze", "targets": {"ko": "프리징", "ja": "フリーズ", "zh": "控线"}},
{"source": "push", "targets": {"ko": "푸시", "ja": "プッシュ", "zh": "推线"}},
{"source": "split push", "targets": {"ko": "스플릿", "ja": "スプリットプッシュ", "zh": "分推"}},
{"source": "back", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
{"source": "recall", "targets": {"ko": "귀환", "ja": "リコール", "zh": "回城"}},
{"source": "ward", "targets": {"ko": "와드", "ja": "ワード", "zh": "眼"}},
{"source": "vision", "targets": {"ko": "시야", "ja": "視界", "zh": "视野"}},
{"source": "flash", "targets": {"ko": "점멸", "ja": "フラッシュ", "zh": "闪现"}},
{"source": "ignite", "targets": {"ko": "점화", "ja": "イグナイト", "zh": "点燃"}},
{"source": "teleport", "targets": {"ko": "순간이동", "ja": "テレポート", "zh": "传送"}},
{"source": "smite", "targets": {"ko": "강타", "ja": "スマイト", "zh": "惩戒"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "大招"}},
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
{"source": "on cooldown", "targets": {"ko": "쿨 도는 중", "ja": "クール中", "zh": "冷却中"}},
{"source": "engage", "targets": {"ko": "이니시", "ja": "エンゲージ", "zh": "开团"}},
{"source": "disengage", "targets": {"ko": "빠지기", "ja": "ディスエンゲージ", "zh": "撤退"}},
{"source": "team fight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
{"source": "teamfight", "targets": {"ko": "한타", "ja": "集団戦", "zh": "团战"}},
{"source": "peel", "targets": {"ko": "피어싱 보호", "ja": "ピール", "zh": "保护"}},
{"source": "kite", "targets": {"ko": "카이팅", "ja": "カイト", "zh": "风筝"}},
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "越塔"}},
{"source": "tower dive", "targets": {"ko": "타워 다이브", "ja": "タワーダイブ", "zh": "越塔强杀"}},
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "团灭"}},
{"source": "first blood", "targets": {"ko": "퍼블", "ja": "ファーストブラッド", "zh": "一血"}},
{"source": "shutdown", "targets": {"ko": "현상금", "ja": "シャットダウン", "zh": "赏金"}},
{"source": "respawn", "targets": {"ko": "부활", "ja": "リスポーン", "zh": "复活"}},
{"source": "missing", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "MIA", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "SS", "targets": {"ko": "실종", "ja": "ミア", "zh": "消失"}},
{"source": "fed", "targets": {"ko": "잘 큰", "ja": "育った", "zh": "养肥"}},
{"source": "scaling", "targets": {"ko": "성장력", "ja": "スケーリング", "zh": "后期"}},
{"source": "objective", "targets": {"ko": "오브젝트", "ja": "オブジェクト", "zh": "资源"}},
{"source": "backdoor", "targets": {"ko": "백도어", "ja": "バックドア", "zh": "偷家"}},
{"source": "surrender", "targets": {"ko": "항복", "ja": "サレンダー", "zh": "投降"}},
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "增益"}},
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
{"source": "CC", "targets": {"ko": "군중제어", "ja": "CC", "zh": "控制"}},
{"source": "stun", "targets": {"ko": "기절", "ja": "スタン", "zh": "眩晕"}},
{"source": "slow", "targets": {"ko": "둔화", "ja": "スロウ", "zh": "减速"}},
{"source": "shield", "targets": {"ko": "실드", "ja": "シールド", "zh": "护盾"}},
{"source": "heal", "targets": {"ko": "회복", "ja": "ヒール", "zh": "治疗"}},
{"source": "mana", "targets": {"ko": "마나", "ja": "マナ", "zh": "蓝"}},
{"source": "low HP", "targets": {"ko": "체력 낮음", "ja": "体力低い", "zh": "残血"}},
{"source": "full build", "targets": {"ko": "풀템", "ja": "フルビルド", "zh": "满装备"}}
]
}

View File

@@ -0,0 +1,45 @@
{
"key": "minecraft",
"name": "마인크래프트",
"description": "블록·몹·아이템 이름. 한국어판 공식 번역어를 따랐습니다.",
"entries": [
{"source": "creeper", "targets": {"ko": "크리퍼", "ja": "クリーパー", "zh": "苦力怕"}},
{"source": "enderman", "targets": {"ko": "엔더맨", "ja": "エンダーマン", "zh": "末影人"}},
{"source": "ender dragon", "targets": {"ko": "엔더 드래곤", "ja": "エンダードラゴン", "zh": "末影龙"}},
{"source": "wither", "targets": {"ko": "위더", "ja": "ウィザー", "zh": "凋灵"}},
{"source": "skeleton", "targets": {"ko": "스켈레톤", "ja": "スケルトン", "zh": "骷髅"}},
{"source": "zombie", "targets": {"ko": "좀비", "ja": "ゾンビ", "zh": "僵尸"}},
{"source": "piglin", "targets": {"ko": "피글린", "ja": "ピグリン", "zh": "猪灵"}},
{"source": "villager", "targets": {"ko": "주민", "ja": "村人", "zh": "村民"}},
{"source": "the Nether", "targets": {"ko": "네더", "ja": "ネザー", "zh": "下界"}},
{"source": "the End", "targets": {"ko": "엔드", "ja": "ジ・エンド", "zh": "末地"}},
{"source": "Overworld", "targets": {"ko": "오버월드", "ja": "オーバーワールド", "zh": "主世界"}},
{"source": "nether portal", "targets": {"ko": "네더 차원문", "ja": "ネザーゲート", "zh": "下界传送门"}},
{"source": "stronghold", "targets": {"ko": "요새", "ja": "要塞", "zh": "要塞"}},
{"source": "bastion", "targets": {"ko": "잔해 성채", "ja": "砦の残骸", "zh": "堡垒遗迹"}},
{"source": "diamond", "targets": {"ko": "다이아몬드", "ja": "ダイヤ", "zh": "钻石"}},
{"source": "netherite", "targets": {"ko": "네더라이트", "ja": "ネザライト", "zh": "下界合金"}},
{"source": "obsidian", "targets": {"ko": "흑요석", "ja": "黒曜石", "zh": "黑曜石"}},
{"source": "redstone", "targets": {"ko": "레드스톤", "ja": "レッドストーン", "zh": "红石"}},
{"source": "ender pearl", "targets": {"ko": "엔더 진주", "ja": "エンダーパール", "zh": "末影珍珠"}},
{"source": "blaze rod", "targets": {"ko": "블레이즈 막대", "ja": "ブレイズロッド", "zh": "烈焰棒"}},
{"source": "elytra", "targets": {"ko": "겉날개", "ja": "エリトラ", "zh": "鞘翅"}},
{"source": "totem", "targets": {"ko": "불사의 토템", "ja": "不死のトーテム", "zh": "不死图腾"}},
{"source": "enchant", "targets": {"ko": "인챈트", "ja": "エンチャント", "zh": "附魔"}},
{"source": "anvil", "targets": {"ko": "모루", "ja": "金床", "zh": "铁砧"}},
{"source": "crafting table", "targets": {"ko": "작업대", "ja": "作業台", "zh": "工作台"}},
{"source": "furnace", "targets": {"ko": "화로", "ja": "かまど", "zh": "熔炉"}},
{"source": "chest", "targets": {"ko": "상자", "ja": "チェスト", "zh": "箱子"}},
{"source": "spawn point", "targets": {"ko": "스폰 지점", "ja": "スポーン地点", "zh": "出生点"}},
{"source": "respawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生"}},
{"source": "biome", "targets": {"ko": "생물 군계", "ja": "バイオーム", "zh": "生物群系"}},
{"source": "seed", "targets": {"ko": "시드", "ja": "シード", "zh": "种子"}},
{"source": "mob", "targets": {"ko": "몹", "ja": "モブ", "zh": "生物"}},
{"source": "hardcore", "targets": {"ko": "하드코어", "ja": "ハードコア", "zh": "极限模式"}},
{"source": "speedrun", "targets": {"ko": "스피드런", "ja": "RTA", "zh": "速通"}},
{"source": "mine", "targets": {"ko": "채굴", "ja": "採掘", "zh": "挖矿"}},
{"source": "craft", "targets": {"ko": "제작", "ja": "クラフト", "zh": "合成"}},
{"source": "build", "targets": {"ko": "건축", "ja": "建築", "zh": "建造"}},
{"source": "farm", "targets": {"ko": "농장", "ja": "トラップ", "zh": "农场"}}
]
}

View File

@@ -0,0 +1,56 @@
{
"key": "overwatch",
"name": "오버워치 2",
"description": "역할·궁·거점 콜. 한국 공식 번역어를 우선했습니다.",
"entries": [
{"source": "tank", "targets": {"ko": "탱커", "ja": "タンク", "zh": "重装"}},
{"source": "DPS", "targets": {"ko": "딜러", "ja": "ダメージ", "zh": "输出"}},
{"source": "damage", "targets": {"ko": "딜", "ja": "ダメージ", "zh": "伤害"}},
{"source": "support", "targets": {"ko": "지원가", "ja": "サポート", "zh": "辅助"}},
{"source": "healer", "targets": {"ko": "힐러", "ja": "ヒーラー", "zh": "奶"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
{"source": "ult charge", "targets": {"ko": "궁 게이지", "ja": "アルトゲージ", "zh": "大招能量"}},
{"source": "ult economy", "targets": {"ko": "궁 경제", "ja": "アルト回し", "zh": "大招经济"}},
{"source": "combo", "targets": {"ko": "연계", "ja": "コンボ", "zh": "连招"}},
{"source": "dive", "targets": {"ko": "다이브", "ja": "ダイブ", "zh": "突进"}},
{"source": "poke", "targets": {"ko": "견제", "ja": "ポーク", "zh": "消耗"}},
{"source": "brawl", "targets": {"ko": "난전", "ja": "ブロール", "zh": "近战团"}},
{"source": "payload", "targets": {"ko": "화물", "ja": "ペイロード", "zh": "运载目标"}},
{"source": "point", "targets": {"ko": "거점", "ja": "拠点", "zh": "点"}},
{"source": "capture", "targets": {"ko": "점령", "ja": "占領", "zh": "占点"}},
{"source": "contest", "targets": {"ko": "저지", "ja": "コンテスト", "zh": "拖点"}},
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
{"source": "checkpoint", "targets": {"ko": "관문", "ja": "チェックポイント", "zh": "检查点"}},
{"source": "spawn", "targets": {"ko": "리스폰", "ja": "リスポーン", "zh": "重生点"}},
{"source": "regroup", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
{"source": "group up", "targets": {"ko": "모이자", "ja": "集合", "zh": "集合"}},
{"source": "stagger", "targets": {"ko": "스태거", "ja": "スタッガー", "zh": "送人头"}},
{"source": "flank", "targets": {"ko": "측면", "ja": "フランク", "zh": "绕后"}},
{"source": "high ground", "targets": {"ko": "고지", "ja": "高台", "zh": "高地"}},
{"source": "cooldown", "targets": {"ko": "쿨", "ja": "クールダウン", "zh": "冷却"}},
{"source": "barrier", "targets": {"ko": "방벽", "ja": "バリア", "zh": "护盾"}},
{"source": "shield", "targets": {"ko": "보호막", "ja": "シールド", "zh": "护盾"}},
{"source": "armor", "targets": {"ko": "방어력", "ja": "アーマー", "zh": "护甲"}},
{"source": "burst", "targets": {"ko": "순간 딜", "ja": "バースト", "zh": "爆发"}},
{"source": "focus", "targets": {"ko": "집중 공격", "ja": "フォーカス", "zh": "集火"}},
{"source": "peel", "targets": {"ko": "보호", "ja": "ピール", "zh": "保护"}},
{"source": "nano", "targets": {"ko": "나노", "ja": "ナノ", "zh": "纳米"}},
{"source": "beat", "targets": {"ko": "비트", "ja": "ビート", "zh": "音障"}},
{"source": "sound barrier", "targets": {"ko": "음파 방벽", "ja": "サウンドバリア", "zh": "音障"}},
{"source": "rez", "targets": {"ko": "부활", "ja": "リザ", "zh": "复活"}},
{"source": "resurrect", "targets": {"ko": "부활", "ja": "リザレクト", "zh": "复活"}},
{"source": "discord", "targets": {"ko": "불화의 구슬", "ja": "ディスコード", "zh": "不和之球"}},
{"source": "hack", "targets": {"ko": "해킹", "ja": "ハック", "zh": "黑入"}},
{"source": "EMP", "targets": {"ko": "EMP", "ja": "EMP", "zh": "EMP"}},
{"source": "team wipe", "targets": {"ko": "전멸", "ja": "全滅", "zh": "团灭"}},
{"source": "team fight", "targets": {"ko": "한타", "ja": "チームファイト", "zh": "团战"}},
{"source": "counter pick", "targets": {"ko": "카운터 픽", "ja": "カウンターピック", "zh": "针对英雄"}},
{"source": "swap", "targets": {"ko": "영웅 교체", "ja": "ヒーロー変更", "zh": "换英雄"}},
{"source": "health pack", "targets": {"ko": "회복팩", "ja": "回復パック", "zh": "血包"}},
{"source": "critical", "targets": {"ko": "치명타", "ja": "クリティカル", "zh": "暴击"}},
{"source": "behind you", "targets": {"ko": "뒤에 있다", "ja": "後ろだ", "zh": "背后有人"}},
{"source": "falling back", "targets": {"ko": "후퇴한다", "ja": "後退する", "zh": "撤退"}},
{"source": "push", "targets": {"ko": "밀자", "ja": "押そう", "zh": "推进"}}
]
}

View File

@@ -0,0 +1,45 @@
{
"key": "streaming",
"name": "방송·스트리밍",
"description": "해외 방송을 볼 때 자주 나오는 말. 게임 용어집과 같이 켜면 좋습니다.",
"entries": [
{"source": "stream", "targets": {"ko": "방송", "ja": "配信", "zh": "直播"}},
{"source": "streamer", "targets": {"ko": "스트리머", "ja": "配信者", "zh": "主播"}},
{"source": "chat", "targets": {"ko": "채팅", "ja": "チャット", "zh": "弹幕"}},
{"source": "donation", "targets": {"ko": "후원", "ja": "投げ銭", "zh": "打赏"}},
{"source": "donate", "targets": {"ko": "후원하다", "ja": "投げ銭する", "zh": "打赏"}},
{"source": "sub", "targets": {"ko": "구독", "ja": "サブスク", "zh": "订阅"}},
{"source": "subscriber", "targets": {"ko": "구독자", "ja": "登録者", "zh": "订阅者"}},
{"source": "follow", "targets": {"ko": "팔로우", "ja": "フォロー", "zh": "关注"}},
{"source": "raid", "targets": {"ko": "합방 몰아주기", "ja": "レイド", "zh": "转播"}},
{"source": "mod", "targets": {"ko": "매니저", "ja": "モデレーター", "zh": "房管"}},
{"source": "clip", "targets": {"ko": "클립", "ja": "クリップ", "zh": "切片"}},
{"source": "highlight", "targets": {"ko": "하이라이트", "ja": "ハイライト", "zh": "高光"}},
{"source": "VOD", "targets": {"ko": "다시보기", "ja": "アーカイブ", "zh": "录播"}},
{"source": "lag", "targets": {"ko": "렉", "ja": "ラグ", "zh": "卡"}},
{"source": "ping", "targets": {"ko": "핑", "ja": "ピン", "zh": "延迟"}},
{"source": "frame drop", "targets": {"ko": "프레임 드랍", "ja": "フレーム落ち", "zh": "掉帧"}},
{"source": "patch", "targets": {"ko": "패치", "ja": "パッチ", "zh": "补丁"}},
{"source": "meta", "targets": {"ko": "메타", "ja": "メタ", "zh": "版本环境"}},
{"source": "nerf", "targets": {"ko": "너프", "ja": "ナーフ", "zh": "削弱"}},
{"source": "buff", "targets": {"ko": "버프", "ja": "バフ", "zh": "加强"}},
{"source": "ranked", "targets": {"ko": "랭크", "ja": "ランク", "zh": "排位"}},
{"source": "casual", "targets": {"ko": "일반전", "ja": "カジュアル", "zh": "休闲"}},
{"source": "smurf", "targets": {"ko": "부계정", "ja": "サブ垢", "zh": "小号"}},
{"source": "grind", "targets": {"ko": "노가다", "ja": "周回", "zh": "刷"}},
{"source": "carry", "targets": {"ko": "캐리", "ja": "キャリー", "zh": "带飞"}},
{"source": "throw", "targets": {"ko": "던지다", "ja": "スローする", "zh": "送"}},
{"source": "griefing", "targets": {"ko": "트롤링", "ja": "荒らし", "zh": "演员"}},
{"source": "toxic", "targets": {"ko": "매너 나쁜", "ja": "民度が低い", "zh": "喷子"}},
{"source": "cracked", "targets": {"ko": "미쳤다", "ja": "ヤバい", "zh": "太强了"}},
{"source": "insane", "targets": {"ko": "대박", "ja": "やばい", "zh": "离谱"}},
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "关键时刻"}},
{"source": "let's go", "targets": {"ko": "가자", "ja": "行くぞ", "zh": "冲"}},
{"source": "no way", "targets": {"ko": "말도 안 돼", "ja": "ありえない", "zh": "不可能吧"}},
{"source": "give me a second", "targets": {"ko": "잠깐만", "ja": "ちょっと待って", "zh": "等一下"}},
{"source": "hold on", "targets": {"ko": "잠깐", "ja": "待って", "zh": "等等"}},
{"source": "be right back", "targets": {"ko": "금방 올게", "ja": "すぐ戻る", "zh": "马上回来"}},
{"source": "thanks for the sub", "targets": {"ko": "구독 고마워", "ja": "サブありがとう", "zh": "谢谢订阅"}},
{"source": "welcome back", "targets": {"ko": "다시 왔네", "ja": "おかえり", "zh": "欢迎回来"}}
]
}

View File

@@ -0,0 +1,66 @@
{
"key": "valorant",
"name": "발로란트",
"description": "스파이크·유틸·이코노미 콜. 아시아 서버 영어 콜아웃에 맞췄습니다.",
"entries": [
{"source": "spike", "targets": {"ko": "스파이크", "ja": "スパイク", "zh": "斩杀者"}},
{"source": "plant", "targets": {"ko": "설치", "ja": "設置", "zh": "安装"}},
{"source": "planted", "targets": {"ko": "설치됐다", "ja": "設置された", "zh": "已安装"}},
{"source": "defuse", "targets": {"ko": "해체", "ja": "解除", "zh": "拆包"}},
{"source": "defusing", "targets": {"ko": "해체 중", "ja": "解除中", "zh": "拆包中"}},
{"source": "fake defuse", "targets": {"ko": "페이크 해체", "ja": "フェイク解除", "zh": "假拆"}},
{"source": "post plant", "targets": {"ko": "설치 후", "ja": "ポストプラント", "zh": "安装后"}},
{"source": "retake", "targets": {"ko": "리테이크", "ja": "リテイク", "zh": "残局夺回"}},
{"source": "site", "targets": {"ko": "사이트", "ja": "サイト", "zh": "包点"}},
{"source": "A site", "targets": {"ko": "A사이트", "ja": "Aサイト", "zh": "A点"}},
{"source": "B site", "targets": {"ko": "B사이트", "ja": "Bサイト", "zh": "B点"}},
{"source": "mid", "targets": {"ko": "미드", "ja": "ミッド", "zh": "中路"}},
{"source": "rotate", "targets": {"ko": "로테", "ja": "ローテ", "zh": "换点"}},
{"source": "rotating", "targets": {"ko": "로테 중", "ja": "ローテ中", "zh": "换点中"}},
{"source": "eco", "targets": {"ko": "이코", "ja": "エコ", "zh": "经济局"}},
{"source": "full eco", "targets": {"ko": "풀이코", "ja": "フルエコ", "zh": "全经济局"}},
{"source": "eco round", "targets": {"ko": "이코 라운드", "ja": "エコラウンド", "zh": "经济局"}},
{"source": "save", "targets": {"ko": "세이브", "ja": "セーブ", "zh": "存钱"}},
{"source": "buy round", "targets": {"ko": "풀 구매", "ja": "フルバイ", "zh": "全起"}},
{"source": "force buy", "targets": {"ko": "강제 구매", "ja": "フォースバイ", "zh": "强起"}},
{"source": "pistol round", "targets": {"ko": "권총 라운드", "ja": "ピストルラウンド", "zh": "手枪局"}},
{"source": "ult", "targets": {"ko": "궁", "ja": "アルト", "zh": "大招"}},
{"source": "ultimate", "targets": {"ko": "궁극기", "ja": "アルティメット", "zh": "终极技能"}},
{"source": "ult orb", "targets": {"ko": "궁 오브", "ja": "アルトオーブ", "zh": "能量球"}},
{"source": "util", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
{"source": "utility", "targets": {"ko": "유틸", "ja": "ユーティリティ", "zh": "技能"}},
{"source": "smoke", "targets": {"ko": "연막", "ja": "スモーク", "zh": "烟"}},
{"source": "flash", "targets": {"ko": "섬광", "ja": "フラッシュ", "zh": "闪"}},
{"source": "flashed", "targets": {"ko": "섬광 맞음", "ja": "フラッシュ食らった", "zh": "被闪了"}},
{"source": "molly", "targets": {"ko": "몰리", "ja": "モリー", "zh": "火"}},
{"source": "wall", "targets": {"ko": "벽", "ja": "ウォール", "zh": "墙"}},
{"source": "recon", "targets": {"ko": "정찰", "ja": "リコン", "zh": "侦察"}},
{"source": "one tap", "targets": {"ko": "원탭", "ja": "ワンタップ", "zh": "一枪"}},
{"source": "headshot", "targets": {"ko": "헤드샷", "ja": "ヘッドショット", "zh": "爆头"}},
{"source": "entry", "targets": {"ko": "진입", "ja": "エントリー", "zh": "突破"}},
{"source": "entry frag", "targets": {"ko": "선취점", "ja": "エントリーキル", "zh": "首杀"}},
{"source": "trade", "targets": {"ko": "트레이드", "ja": "トレード", "zh": "换人"}},
{"source": "peek", "targets": {"ko": "픽", "ja": "ピーク", "zh": "架点"}},
{"source": "wide peek", "targets": {"ko": "와이드 픽", "ja": "ワイドピーク", "zh": "大幅架点"}},
{"source": "clear", "targets": {"ko": "클리어", "ja": "クリア", "zh": "清点"}},
{"source": "rush", "targets": {"ko": "러쉬", "ja": "ラッシュ", "zh": "强攻"}},
{"source": "stack", "targets": {"ko": "몰빵", "ja": "スタック", "zh": "集火"}},
{"source": "lurk", "targets": {"ko": "럴커", "ja": "ラーク", "zh": "潜伏"}},
{"source": "op", "targets": {"ko": "오페", "ja": "オペ", "zh": "狙"}},
{"source": "operator", "targets": {"ko": "오퍼레이터", "ja": "オペレーター", "zh": "狙击枪"}},
{"source": "clutch", "targets": {"ko": "클러치", "ja": "クラッチ", "zh": "残局"}},
{"source": "ace", "targets": {"ko": "에이스", "ja": "エース", "zh": "一穿五"}},
{"source": "spray", "targets": {"ko": "연사", "ja": "スプレー", "zh": "扫射"}},
{"source": "crosshair", "targets": {"ko": "크로스헤어", "ja": "クロスヘア", "zh": "准星"}},
{"source": "callout", "targets": {"ko": "콜", "ja": "コール", "zh": "报点"}},
{"source": "spotted", "targets": {"ko": "봤다", "ja": "発見", "zh": "看到了"}},
{"source": "one down", "targets": {"ko": "한 명 잡음", "ja": "一人ダウン", "zh": "倒一个"}},
{"source": "man advantage", "targets": {"ko": "인원 우위", "ja": "人数有利", "zh": "人数优势"}},
{"source": "low", "targets": {"ko": "피 없음", "ja": "体力低い", "zh": "残血"}},
{"source": "armor", "targets": {"ko": "방어구", "ja": "アーマー", "zh": "护甲"}},
{"source": "creds", "targets": {"ko": "크레딧", "ja": "クレジット", "zh": "钱"}},
{"source": "drop", "targets": {"ko": "드랍", "ja": "ドロップ", "zh": "丢枪"}},
{"source": "overtime", "targets": {"ko": "연장", "ja": "オーバータイム", "zh": "加时"}},
{"source": "match point", "targets": {"ko": "매치포인트", "ja": "マッチポイント", "zh": "赛点"}}
]
}

173
src/livesub/ui/hotkeys.py Normal file
View File

@@ -0,0 +1,173 @@
"""전역 단축키.
Qt 의 QShortcut 은 우리 창이 포커스를 가졌을 때만 동작한다. 게임을 하는 중에
자막을 껐다 켜야 하므로 OS 레벨 단축키가 필요하다. Windows 에서는
`RegisterHotKey` + 네이티브 이벤트 필터로 구현하고, 다른 OS 에서는 조용히
아무 것도 하지 않는다(창이 떠 있을 때만 쓰는 QShortcut 은 상위에서 따로 건다).
"""
from __future__ import annotations
import logging
import sys
from collections.abc import Callable
from PySide6.QtCore import QAbstractNativeEventFilter, QObject
log = logging.getLogger(__name__)
WM_HOTKEY = 0x0312
MOD_ALT = 0x0001
MOD_CONTROL = 0x0002
MOD_SHIFT = 0x0004
MOD_WIN = 0x0008
MOD_NOREPEAT = 0x4000
_MODIFIERS = {
"ctrl": MOD_CONTROL,
"control": MOD_CONTROL,
"alt": MOD_ALT,
"shift": MOD_SHIFT,
"win": MOD_WIN,
"meta": MOD_WIN,
}
#: 문자/숫자가 아닌 키의 가상 키 코드
_NAMED_KEYS = {
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, "f5": 0x74, "f6": 0x75,
"f7": 0x76, "f8": 0x77, "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"space": 0x20, "enter": 0x0D, "return": 0x0D, "tab": 0x09, "esc": 0x1B,
"escape": 0x1B, "insert": 0x2D, "delete": 0x2E, "home": 0x24, "end": 0x23,
"pageup": 0x21, "pagedown": 0x22, "up": 0x26, "down": 0x28,
"left": 0x25, "right": 0x27, "`": 0xC0, "-": 0xBD, "=": 0xBB,
}
class HotkeyError(RuntimeError):
"""단축키를 등록할 수 없을 때 (대개 다른 프로그램이 선점)."""
def parse(sequence: str) -> tuple[int, int]:
""""Ctrl+Alt+S" → (수정자 비트마스크, 가상 키 코드)."""
parts = [p.strip().lower() for p in sequence.split("+") if p.strip()]
if not parts:
raise HotkeyError(f"빈 단축키입니다: {sequence!r}")
mods = 0
key_part = ""
for part in parts:
if part in _MODIFIERS:
mods |= _MODIFIERS[part]
else:
key_part = part
if not key_part:
raise HotkeyError(f"조합키만 있고 실제 키가 없습니다: {sequence!r}")
if not mods:
# 수정자 없는 전역 단축키는 게임 입력을 통째로 가로채므로 막는다.
raise HotkeyError(f"Ctrl/Alt/Shift 중 하나는 있어야 합니다: {sequence!r}")
if key_part in _NAMED_KEYS:
vk = _NAMED_KEYS[key_part]
elif len(key_part) == 1 and key_part.isalnum():
vk = ord(key_part.upper())
else:
raise HotkeyError(f"알 수 없는 키입니다: {key_part!r}")
return mods | MOD_NOREPEAT, vk
class GlobalHotkeys(QObject, QAbstractNativeEventFilter):
"""전역 단축키 묶음. Windows 전용이며 그 외에서는 no-op."""
def __init__(self, parent: QObject | None = None) -> None:
QObject.__init__(self, parent)
QAbstractNativeEventFilter.__init__(self)
self._callbacks: dict[int, Callable[[], None]] = {}
self._next_id = 1
self._installed = False
self._last_error = ""
@property
def supported(self) -> bool:
return sys.platform == "win32"
@property
def last_error(self) -> str:
return self._last_error
def register(self, sequence: str, callback: Callable[[], None]) -> bool:
"""단축키 하나를 등록한다. 성공하면 True."""
if not self.supported:
self._last_error = "전역 단축키는 Windows에서만 지원합니다."
return False
try:
mods, vk = parse(sequence)
except HotkeyError as exc:
self._last_error = str(exc)
log.warning("단축키 해석 실패: %s", exc)
return False
import ctypes
hotkey_id = self._next_id
if not ctypes.windll.user32.RegisterHotKey(None, hotkey_id, mods, vk):
self._last_error = (
f"'{sequence}' 를 등록하지 못했습니다. 다른 프로그램이 쓰고 있을 수 있습니다."
)
log.warning(self._last_error)
return False
self._callbacks[hotkey_id] = callback
self._next_id += 1
self._ensure_filter()
log.info("전역 단축키 등록: %s", sequence)
return True
def unregister_all(self) -> None:
if not self.supported or not self._callbacks:
return
import ctypes
from PySide6.QtWidgets import QApplication
for hotkey_id in self._callbacks:
ctypes.windll.user32.UnregisterHotKey(None, hotkey_id)
self._callbacks.clear()
if self._installed:
app = QApplication.instance()
if app is not None:
app.removeNativeEventFilter(self)
self._installed = False
def _ensure_filter(self) -> None:
if self._installed:
return
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.installNativeEventFilter(self)
self._installed = True
def nativeEventFilter(self, event_type, message): # noqa: N802 - Qt 규약
if not self._callbacks or event_type not in (b"windows_generic_MSG",
b"windows_dispatcher_MSG"):
return False, 0
import ctypes
import ctypes.wintypes
msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents
if msg.message != WM_HOTKEY:
return False, 0
callback = self._callbacks.get(int(msg.wParam))
if callback is None:
return False, 0
try:
callback()
except Exception: # noqa: BLE001 - 콜백 오류로 이벤트 루프가 죽으면 안 된다
log.exception("단축키 처리 실패")
return True, 0

View File

@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
from ..config import AppConfig from ..config import AppConfig
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
from .hotkeys import GlobalHotkeys
from .overlay import SubtitleOverlay from .overlay import SubtitleOverlay
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
from .theme import SPACING, palette, stylesheet from .theme import SPACING, palette, stylesheet
@@ -60,10 +61,12 @@ class MainWindow(QMainWindow):
on_status=self.status_received.emit, on_status=self.status_received.emit,
) )
self.overlay = SubtitleOverlay(config.subtitle, config.overlay) self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
self.hotkeys = GlobalHotkeys(self)
self._transcript_file = None self._transcript_file = None
self._build_ui() self._build_ui()
self._connect() self._connect()
self._install_hotkeys()
self._save_timer = QTimer(self) self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True) self._save_timer.setSingleShot(True)
@@ -151,17 +154,69 @@ class MainWindow(QMainWindow):
self.subtitle_page.style_changed.connect(self._on_style_changed) self.subtitle_page.style_changed.connect(self._on_style_changed)
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled) self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
self.subtitle_page.placement_changed.connect(self._on_placement_changed)
self.glossary_page.glossary_changed.connect(self._on_glossary_changed) self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
self.settings_page.config_changed.connect(self._schedule_save) self.settings_page.config_changed.connect(self._schedule_save)
self.settings_page.restart_needed.connect(self._on_restart_needed) self.settings_page.restart_needed.connect(self._on_restart_needed)
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
self.overlay.geometry_changed.connect(lambda *_: self._schedule_save()) self.overlay.geometry_changed.connect(self._on_overlay_moved)
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
self.overlay.closed.connect( self.overlay.closed.connect(
lambda: self.subtitle_page.overlay_check.setChecked(False) lambda: self.subtitle_page.overlay_check.setChecked(False)
) )
# --- 전역 단축키 ------------------------------------------------------
def _install_hotkeys(self) -> None:
"""설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다."""
self.hotkeys.unregister_all()
keys = self.config.hotkeys
if not keys.enabled:
self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.")
return
if not self.hotkeys.supported:
self.settings_page.set_hotkey_status(
"전역 단축키는 Windows에서만 동작합니다. "
"이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.",
ok=False,
)
return
registered, failed = [], []
for label, sequence, callback in (
("자막", keys.toggle_subtitle, self.toggle_subtitle),
("번역", keys.toggle_engine, self.toggle_engine),
):
if not sequence:
continue
if self.hotkeys.register(sequence, callback):
registered.append(f"{label} {sequence}")
else:
failed.append(f"{label} {sequence}")
if failed:
self.settings_page.set_hotkey_status(
f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False
)
else:
self.settings_page.set_hotkey_status(
f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다."
)
def toggle_subtitle(self) -> None:
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
visible = self.overlay.toggle_subtitle()
self._schedule_save()
log.info("자막 %s", "켬" if visible else "끔")
def toggle_engine(self) -> None:
if self.engine.running:
self.stop_engine()
else:
self.start_engine()
# --- 엔진 ----------------------------------------------------------- # --- 엔진 -----------------------------------------------------------
def start_engine(self) -> None: def start_engine(self) -> None:
if self.config.overlay.visible: if self.config.overlay.visible:
@@ -204,8 +259,21 @@ class MainWindow(QMainWindow):
self._schedule_save() self._schedule_save()
def _on_overlay_toggled(self, visible: bool) -> None: def _on_overlay_toggled(self, visible: bool) -> None:
self.config.overlay.visible = visible if visible == self.config.overlay.visible:
self.overlay.setVisible(visible) return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단
self.overlay.set_subtitle_visible(visible)
self._schedule_save()
def _on_placement_changed(self) -> None:
"""모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영."""
self.overlay.apply_config()
self.overlay.snap_to_anchor()
self.subtitle_page.sync_from_config()
self._schedule_save()
def _on_overlay_moved(self, *_) -> None:
"""드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다."""
self.subtitle_page.sync_from_config()
self._schedule_save() self._schedule_save()
def _on_glossary_changed(self) -> None: def _on_glossary_changed(self) -> None:
@@ -240,6 +308,7 @@ class MainWindow(QMainWindow):
# --- 종료 ----------------------------------------------------------- # --- 종료 -----------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.config.save() self.config.save()
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
self.engine.shutdown() self.engine.shutdown()
self._close_transcript() self._close_transcript()
self.overlay.close() self.overlay.close()

View File

@@ -22,9 +22,10 @@ from PySide6.QtGui import (
QPainterPath, QPainterPath,
QPen, QPen,
) )
from PySide6.QtWidgets import QApplication, QMenu, QWidget from PySide6.QtWidgets import QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle from ..config import OverlayConfig, SubtitleStyle
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
GRIP = 18 # 우하단 리사이즈 핸들 크기 GRIP = 18 # 우하단 리사이즈 핸들 크기
@@ -142,6 +143,8 @@ class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창.""" """항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int) geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
closed = Signal() closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None: def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
@@ -182,17 +185,45 @@ class SubtitleOverlay(QWidget):
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
) )
if cfg.x < 0 or cfg.y < 0: # 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
screen = QApplication.primaryScreen() if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
area = screen.availableGeometry() if screen else QRect(0, 0, 1920, 1080) self.snap_to_anchor(save_only=True)
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) self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
if cfg.visible: self.setVisible(cfg.visible)
self.show()
self.update() self.update()
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
cfg = self.overlay_cfg
if anchor is not None:
cfg.anchor = anchor
if cfg.anchor == ANCHOR_CUSTOM:
return
screens = list_screens()
screen = pick_screen(screens, cfg.screen_index)
if screen is None:
return
cfg.screen_index = screen.index
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
)
if not save_only:
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
def set_subtitle_visible(self, visible: bool) -> None:
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
self.overlay_cfg.visible = visible
self.setVisible(visible)
if not visible:
self._clear_text()
self.visibility_changed.emit(visible)
def toggle_subtitle(self) -> bool:
self.set_subtitle_visible(not self.overlay_cfg.visible)
return self.overlay_cfg.visible
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None: def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
if is_final: if is_final:
self._final_text = translated or source self._final_text = translated or source
@@ -260,13 +291,22 @@ class SubtitleOverlay(QWidget):
self.move(event.globalPosition().toPoint() - self._drag_from) self.move(event.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, event) -> None: # noqa: N802 def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is not None or self._resize_from is not None: if self._drag_from is None and self._resize_from is None:
self._drag_from = None return
self._resize_from = None self._drag_from = None
g = self.geometry() self._resize_from = None
self.overlay_cfg.x, self.overlay_cfg.y = g.x(), g.y()
self.overlay_cfg.width, self.overlay_cfg.height = g.width(), g.height() cfg = self.overlay_cfg
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height()) g = self.geometry()
cfg.x, cfg.y = g.x(), g.y()
cfg.width, cfg.height = g.width(), g.height()
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
cfg.anchor = ANCHOR_CUSTOM
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
if owner is not None:
cfg.screen_index = owner.index
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
def wheelEvent(self, event) -> None: # noqa: N802 def wheelEvent(self, event) -> None: # noqa: N802
if self.overlay_cfg.locked: if self.overlay_cfg.locked:
@@ -311,8 +351,7 @@ class SubtitleOverlay(QWidget):
self.apply_config() self.apply_config()
def hide_overlay(self) -> None: def hide_overlay(self) -> None:
self.overlay_cfg.visible = False self.set_subtitle_visible(False)
self.hide()
self.closed.emit() self.closed.emit()
def _restart_fade(self) -> None: def _restart_fade(self) -> None:

View File

@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry from ...models import Glossary, GlossaryEntry
from ...models.packs import available_packs
from ..theme import SPACING from ..theme import SPACING
from ..widgets import Card, PageHeader from ..widgets import Card, PageHeader
@@ -50,10 +51,50 @@ class GlossaryPage(QWidget):
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.", "모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
) )
) )
root.addWidget(self._build_packs_card())
root.addWidget(self._build_options_card()) root.addWidget(self._build_options_card())
root.addWidget(self._build_table_card(), 1) root.addWidget(self._build_table_card(), 1)
self._reload_table() self._reload_table()
# --- 기본 제공 팩 ----------------------------------------------------
def _build_packs_card(self) -> Card:
card = Card(
"게임별 기본 용어집",
"체크만 하면 바로 적용됩니다. 역어가 마음에 안 들면 아래 표에 같은 "
"단어를 등록하세요 — 직접 등록한 쪽이 항상 이깁니다.",
)
self._pack_checks: dict[str, QCheckBox] = {}
enabled = set(self.config.glossary.enabled_packs)
packs = available_packs()
if not packs:
card.add(_hint("기본 제공 용어집을 찾지 못했습니다."))
return card
for pack in packs:
check = QCheckBox(f"{pack.name} ({len(pack)}개)")
check.setChecked(pack.key in enabled)
check.setToolTip(pack.description)
check.toggled.connect(self._on_packs_changed)
self._pack_checks[pack.key] = check
card.add(check)
self.pack_total = card.add(_hint(""))
self._update_pack_total()
return card
def _on_packs_changed(self, *_) -> None:
self.config.glossary.enabled_packs = [
key for key, check in self._pack_checks.items() if check.isChecked()
]
self._update_pack_total()
self.glossary_changed.emit()
def _update_pack_total(self) -> None:
enabled = set(self.config.glossary.enabled_packs)
total = sum(len(p) for p in available_packs() if p.key in enabled)
self.pack_total.setText(f"켜둔 기본 용어 {total}개")
# --- 구성 ----------------------------------------------------------- # --- 구성 -----------------------------------------------------------
def _build_options_card(self) -> Card: def _build_options_card(self) -> Card:
card = Card() card = Card()

View File

@@ -146,7 +146,7 @@ class HomePage(QWidget):
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다." msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]: elif caps["device"]:
msg = ( msg = (
"프로그램별 캡처 보조 프로그램(hearo_capture.exe)이 없어 " "프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 " "출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다." "프로그램 단위로 분리됩니다."
) )

View File

@@ -0,0 +1,324 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class SettingsPage(QWidget):
config_changed = Signal()
#: 오디오 파이프라인 설정이 바뀌어 엔진 재시작이 필요할 때
restart_needed = Signal()
#: 전역 단축키를 다시 등록해야 할 때
hotkeys_changed = 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("설정", "게임과 같이 돌릴 때의 GPU 부담, 단축키, 인식 민감도를 조절합니다.")
)
root.addWidget(self._build_performance_card())
root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card())
root.addWidget(self._build_about_card())
root.addStretch(1)
# --- GPU 부담 --------------------------------------------------------
def _build_performance_card(self) -> Card:
perf = self.config.performance
card = Card(
"게임과 같이 쓰기 (GPU 절약)",
"게임이 먼저입니다. 이 모드를 켜면 자막 품질을 조금 내주는 대신 "
"게임 프레임과 VRAM을 지킵니다.",
)
form = card.add_form()
self.game_mode_check = QCheckBox("저부하 모드 (권장)")
self.game_mode_check.setChecked(perf.game_mode)
self.game_mode_check.setToolTip(
"연산 정밀도를 int8로 낮추고, 추론 사이에 GPU를 잠깐 쉬게 하고, "
"조용할 때 모델을 내립니다."
)
self.game_mode_check.toggled.connect(self._on_performance_changed)
form.addRow("", self.game_mode_check)
self.vram_slider, vram_row = _slider_row(
int(perf.vram_limit_ratio * 100), 0, 90, "%", self._on_performance_changed
)
self.vram_slider.setToolTip(
"이 프로그램이 쓸 수 있는 VRAM 상한입니다. 0%면 제한하지 않습니다.\n"
"게임이 VRAM 부족으로 버벅이면 낮추세요."
)
form.addRow("VRAM 상한", vram_row)
self.yield_spin = _spin(perf.yield_ms, 0, 500, 10, " ms", self._on_performance_changed)
self.yield_spin.setToolTip(
"한 문장을 처리하고 나서 GPU를 놓아주는 시간입니다.\n"
"길수록 게임이 부드럽고 자막은 조금 늦게 뜹니다."
)
form.addRow("GPU 양보 시간", self.yield_spin)
self.idle_spin = _spin(
perf.idle_unload_s, 0, 3600, 30, " 초", self._on_performance_changed
)
self.idle_spin.setToolTip(
"이 시간 동안 말소리가 없으면 모델을 내려 VRAM을 통째로 반납합니다.\n"
"다음 대사가 나오면 자동으로 다시 올라갑니다. 0이면 끕니다."
)
form.addRow("유휴 시 모델 해제", self.idle_spin)
self.perf_summary = QLabel()
self.perf_summary.setObjectName("Hint")
self.perf_summary.setWordWrap(True)
card.add(self.perf_summary)
self._update_perf_summary()
return card
def _update_perf_summary(self) -> None:
perf = self.config.performance
gpu = detect_gpu()
if not gpu.available or not gpu.total_vram_mb:
self.perf_summary.setText("GPU 정보를 읽지 못해 상한을 계산할 수 없습니다.")
return
if perf.vram_limit_ratio <= 0:
self.perf_summary.setText(
f"{gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB — 상한 없음 "
"(게임이 VRAM 부족으로 버벅일 수 있습니다)"
)
return
budget = gpu.total_vram_mb * perf.vram_limit_ratio / 1024
left = gpu.total_vram_mb / 1024 - budget
self.perf_summary.setText(
f"{gpu.name} · 자막이 최대 {budget:.1f}GB 사용, 게임 몫 약 {left:.1f}GB"
)
# --- 단축키 ----------------------------------------------------------
def _build_hotkey_card(self) -> Card:
keys = self.config.hotkeys
card = Card(
"전역 단축키",
"게임 창이 떠 있어도 동작합니다. Ctrl / Alt / Shift 중 하나는 반드시 넣으세요.",
)
form = card.add_form()
self.hotkey_enabled = QCheckBox("전역 단축키 사용")
self.hotkey_enabled.setChecked(keys.enabled)
self.hotkey_enabled.toggled.connect(self._on_hotkey_changed)
form.addRow("", self.hotkey_enabled)
self.subtitle_hotkey = QLineEdit(keys.toggle_subtitle)
self.subtitle_hotkey.setPlaceholderText("예: Ctrl+Alt+S")
self.subtitle_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("자막 켜기/끄기", self.subtitle_hotkey)
self.engine_hotkey = QLineEdit(keys.toggle_engine)
self.engine_hotkey.setPlaceholderText("예: Ctrl+Alt+D")
self.engine_hotkey.editingFinished.connect(self._on_hotkey_changed)
form.addRow("번역 시작/정지", self.engine_hotkey)
self.hotkey_status = QLabel()
self.hotkey_status.setObjectName("Hint")
self.hotkey_status.setWordWrap(True)
card.add(self.hotkey_status)
return card
def set_hotkey_status(self, message: str, ok: bool = True) -> None:
p = palette("dark")
self.hotkey_status.setText(message)
self.hotkey_status.setStyleSheet(f"color: {p.text_dim if ok else p.warning};")
def _on_performance_changed(self, *_) -> None:
perf = self.config.performance
perf.game_mode = self.game_mode_check.isChecked()
perf.vram_limit_ratio = self.vram_slider.value() / 100
perf.yield_ms = self.yield_spin.value()
perf.idle_unload_s = self.idle_spin.value()
for widget in (self.vram_slider, self.yield_spin, self.idle_spin):
widget.setEnabled(perf.game_mode)
self._update_perf_summary()
if hasattr(self, "partial_spin"): # 오디오 카드가 아직 안 만들어졌을 수 있다
self._sync_partial_enabled()
self.config_changed.emit()
self.restart_needed.emit()
def _on_hotkey_changed(self, *_) -> None:
keys = self.config.hotkeys
keys.enabled = self.hotkey_enabled.isChecked()
keys.toggle_subtitle = self.subtitle_hotkey.text().strip()
keys.toggle_engine = self.engine_hotkey.text().strip()
for widget in (self.subtitle_hotkey, self.engine_hotkey):
widget.setEnabled(keys.enabled)
self.config_changed.emit()
self.hotkeys_changed.emit()
# --- 구성 -----------------------------------------------------------
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(
"말하는 도중에도 인식 중인 내용을 흐리게 미리 보여줍니다.\n"
"같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 됩니다.\n"
"저부하 모드가 켜져 있으면 무시됩니다."
)
self.partial_row_label = QLabel("중간 결과 갱신")
form.addRow(self.partial_row_label, self.partial_spin)
self._sync_partial_enabled()
return card
def _sync_partial_enabled(self) -> None:
"""저부하 모드에서는 중간 결과를 쓰지 않으므로 입력을 잠근다."""
on = not self.config.performance.game_mode
self.partial_spin.setEnabled(on)
self.partial_row_label.setText(
"중간 결과 갱신" if on else "중간 결과 갱신 (저부하 모드에서 꺼짐)"
)
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()
row.setObjectName("Row")
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

@@ -20,8 +20,9 @@ from PySide6.QtWidgets import (
from ...config import AppConfig from ...config import AppConfig
from ..overlay import SubtitlePreview from ..overlay import SubtitlePreview
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
from ..theme import SPACING from ..theme import SPACING
from ..widgets import Card, PageHeader from ..widgets import AnchorGrid, Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!" PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!" PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
@@ -56,7 +57,10 @@ class ColorButton(QPushButton):
class SubtitlePage(QWidget): class SubtitlePage(QWidget):
style_changed = Signal() style_changed = Signal()
#: 자막 표시 on/off
overlay_toggled = Signal(bool) overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None): def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent) super().__init__(parent)
@@ -66,9 +70,10 @@ class SubtitlePage(QWidget):
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4) root.setSpacing(SPACING + 4)
root.addWidget( root.addWidget(
PageHeader("자막", "자막 창에 표시될 글씨의 모양을 정합니다. 아래에서 바로 확인하세요.") PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
) )
root.addWidget(self._build_display_card())
root.addWidget(self._build_preview_card()) root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card()) root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card()) root.addWidget(self._build_layout_card())
@@ -77,6 +82,86 @@ class SubtitlePage(QWidget):
self._sync_preview() self._sync_preview()
# --- 구성 ----------------------------------------------------------- # --- 구성 -----------------------------------------------------------
def _build_display_card(self) -> Card:
"""자막 on/off + 모니터 선택 + 디스코드식 위치 선택."""
cfg = self.config.overlay
card = Card(
"표시 위치",
"모니터를 고르고 네모칸을 누르면 그 자리에 자막이 붙습니다. "
"직접 드래그해서 옮기면 그 위치를 그대로 기억합니다.",
)
# --- on/off + 단축키 안내 ---
toggle_row = QWidget()
toggle_layout = QHBoxLayout(toggle_row)
toggle_layout.setContentsMargins(0, 0, 0, 0)
toggle_layout.setSpacing(10)
self.overlay_check = QCheckBox("자막 켜기")
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.setStyleSheet("font-weight: 700;")
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
toggle_layout.addWidget(self.overlay_check)
hotkey_hint = QLabel(f"게임 중에도 {self.config.hotkeys.toggle_subtitle} 로 켜고 끌 수 있습니다")
hotkey_hint.setObjectName("Hint")
toggle_layout.addWidget(hotkey_hint)
toggle_layout.addStretch(1)
self.click_through_check = QCheckBox("클릭 통과")
self.click_through_check.setChecked(cfg.click_through)
self.click_through_check.setToolTip(
"켜면 자막 위를 클릭해도 게임으로 전달됩니다. 게임 중에는 켜두세요."
)
self.click_through_check.toggled.connect(self._on_placement_changed)
toggle_layout.addWidget(self.click_through_check)
card.add(toggle_row)
# --- 모니터 선택 ---
monitor_row = QWidget()
monitor_layout = QHBoxLayout(monitor_row)
monitor_layout.setContentsMargins(0, 0, 0, 0)
monitor_layout.setSpacing(10)
monitor_layout.addWidget(QLabel("모니터"))
self.screen_combo = QComboBox()
self.screen_combo.currentIndexChanged.connect(self._on_screen_changed)
monitor_layout.addWidget(self.screen_combo, 1)
refresh = QPushButton("새로고침")
refresh.setToolTip("모니터를 연결하거나 뺐다면 눌러주세요.")
refresh.clicked.connect(self.refresh_screens)
monitor_layout.addWidget(refresh)
card.add(monitor_row)
# --- 9분할 위치 ---
self.anchor_grid = AnchorGrid()
self.anchor_grid.anchor_selected.connect(self._on_anchor_selected)
card.add(self.anchor_grid)
self.anchor_label = QLabel()
self.anchor_label.setObjectName("Hint")
self.anchor_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
card.add(self.anchor_label)
margin_row = QWidget()
margin_layout = QHBoxLayout(margin_row)
margin_layout.setContentsMargins(0, 0, 0, 0)
margin_layout.setSpacing(10)
margin_layout.addWidget(QLabel("가장자리 여백"))
self.margin_spin = QSpinBox()
self.margin_spin.setRange(0, 400)
self.margin_spin.setSingleStep(10)
self.margin_spin.setSuffix(" px")
self.margin_spin.setValue(cfg.margin)
self.margin_spin.valueChanged.connect(self._on_placement_changed)
margin_layout.addWidget(self.margin_spin)
margin_layout.addStretch(1)
card.add(margin_row)
self.refresh_screens()
return card
def _build_preview_card(self) -> Card: def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.") card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle) self.preview = SubtitlePreview(self.config.subtitle)
@@ -140,6 +225,7 @@ class SubtitlePage(QWidget):
form.addRow("배경 색", self.bg_color) form.addRow("배경 색", self.bg_color)
opacity_row = QWidget() opacity_row = QWidget()
opacity_row.setObjectName("Row")
layout = QHBoxLayout(opacity_row) layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal) self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
@@ -180,12 +266,70 @@ class SubtitlePage(QWidget):
self.show_source_check.toggled.connect(self._on_change) self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check) 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 return card
# --- 표시 위치 -------------------------------------------------------
def refresh_screens(self) -> None:
"""연결된 모니터를 다시 읽어 콤보를 채운다."""
cfg = self.config.overlay
screens = list_screens()
self.screen_combo.blockSignals(True)
self.screen_combo.clear()
for screen in screens:
self.screen_combo.addItem(screen.label, screen.index)
if not screens:
self.screen_combo.addItem("모니터를 찾지 못했습니다", -1)
found = self.screen_combo.findData(cfg.screen_index)
self.screen_combo.setCurrentIndex(found if found >= 0 else 0)
self.screen_combo.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def sync_from_config(self) -> None:
"""드래그 등으로 설정이 바뀐 뒤 화면 표시를 맞춘다."""
cfg = self.config.overlay
self.overlay_check.blockSignals(True)
self.overlay_check.setChecked(cfg.visible)
self.overlay_check.blockSignals(False)
self.anchor_grid.set_anchor(cfg.anchor)
self._update_anchor_label()
def _update_anchor_label(self) -> None:
anchor = self.config.overlay.anchor
if anchor == ANCHOR_CUSTOM:
self.anchor_label.setText(
"자유 배치 — 직접 옮긴 자리를 쓰고 있습니다. 칸을 누르면 다시 맞춰집니다."
)
else:
self.anchor_label.setText(f"현재 위치: {ANCHOR_LABELS.get(anchor, anchor)}")
def _on_screen_changed(self) -> None:
index = self.screen_combo.currentData()
if index is None or index < 0:
return
self.config.overlay.screen_index = index
# 모니터를 바꾸면 그 모니터 기준으로 다시 배치해야 한다. 자유 배치
# 상태였다면 하단 중앙으로 되돌린다 — 이전 좌표는 다른 모니터 것이라
# 그대로 쓰면 화면 밖으로 나간다.
if self.config.overlay.anchor == ANCHOR_CUSTOM:
self.config.overlay.anchor = "bottom-center"
self.anchor_grid.set_anchor("bottom-center")
self._update_anchor_label()
self.placement_changed.emit()
def _on_anchor_selected(self, anchor: str) -> None:
self.config.overlay.anchor = anchor
self._update_anchor_label()
self.placement_changed.emit()
def _on_placement_changed(self, *_) -> None:
cfg = self.config.overlay
cfg.margin = self.margin_spin.value()
cfg.click_through = self.click_through_check.isChecked()
self.placement_changed.emit()
# --- 동작 ----------------------------------------------------------- # --- 동작 -----------------------------------------------------------
def _on_font_changed(self, font: QFont) -> None: def _on_font_changed(self, font: QFont) -> None:
self.config.subtitle.font_family = font.family() self.config.subtitle.font_family = font.family()

141
src/livesub/ui/placement.py Normal file
View File

@@ -0,0 +1,141 @@
"""자막 창 배치 계산.
디스코드 오버레이와 같은 방식이다. 모니터를 고르고 9개 구역 중 하나를 누르면
그 자리에 딱 붙는다. 세밀하게 옮기고 싶으면 그 다음에 드래그하면 된다.
Qt 에 의존하지 않는 순수 계산부(`resolve_rect`)와 Qt 화면 조회부를 나눠놨다.
덕분에 모니터가 없는 환경에서도 배치 로직을 그대로 테스트할 수 있다.
"""
from __future__ import annotations
from dataclasses import dataclass
#: 디스코드 오버레이와 같은 3x3 그리드. 값은 (가로 정렬, 세로 정렬).
ANCHORS: dict[str, tuple[str, str]] = {
"top-left": ("left", "top"),
"top-center": ("center", "top"),
"top-right": ("right", "top"),
"middle-left": ("left", "middle"),
"middle-center": ("center", "middle"),
"middle-right": ("right", "middle"),
"bottom-left": ("left", "bottom"),
"bottom-center": ("center", "bottom"),
"bottom-right": ("right", "bottom"),
}
#: UI 표시용 이름 (그리드 순서와 동일)
ANCHOR_LABELS: dict[str, str] = {
"top-left": "좌상단",
"top-center": "상단 중앙",
"top-right": "우상단",
"middle-left": "좌측 중앙",
"middle-center": "정중앙",
"middle-right": "우측 중앙",
"bottom-left": "좌하단",
"bottom-center": "하단 중앙",
"bottom-right": "우하단",
}
#: 자유 배치 — 사용자가 드래그로 직접 옮긴 상태
ANCHOR_CUSTOM = "custom"
DEFAULT_ANCHOR = "bottom-center"
@dataclass(frozen=True)
class ScreenInfo:
"""모니터 하나. Qt 의 QScreen 에서 뽑아낸 값만 담는다."""
index: int
name: str
x: int
y: int
width: int
height: int
is_primary: bool = False
@property
def label(self) -> str:
tag = " (주 모니터)" if self.is_primary else ""
return f"{self.index + 1}. {self.name} · {self.width}×{self.height}{tag}"
def resolve_rect(
screen: ScreenInfo,
anchor: str,
width: int,
height: int,
margin: int = 60,
) -> tuple[int, int, int, int]:
"""앵커와 크기로 최종 창 위치를 계산한다.
창이 모니터보다 크면 모니터 안에 들어가도록 줄인다. 그러지 않으면
자막이 화면 밖으로 나가 영영 안 보이는 상태가 될 수 있다.
"""
width = max(200, min(width, screen.width - margin * 2))
height = max(80, min(height, screen.height - margin * 2))
horizontal, vertical = ANCHORS.get(anchor, ANCHORS[DEFAULT_ANCHOR])
if horizontal == "left":
x = screen.x + margin
elif horizontal == "right":
x = screen.x + screen.width - width - margin
else:
x = screen.x + (screen.width - width) // 2
if vertical == "top":
y = screen.y + margin
elif vertical == "bottom":
y = screen.y + screen.height - height - margin
else:
y = screen.y + (screen.height - height) // 2
return x, y, width, height
def screen_for_point(screens: list[ScreenInfo], x: int, y: int) -> ScreenInfo | None:
"""해당 좌표를 품고 있는 모니터. 드래그로 옮긴 뒤 소속을 다시 찾을 때 쓴다."""
for screen in screens:
if screen.x <= x < screen.x + screen.width and screen.y <= y < screen.y + screen.height:
return screen
return None
def pick_screen(screens: list[ScreenInfo], index: int) -> ScreenInfo | None:
"""저장된 인덱스로 모니터를 찾는다. 모니터 구성이 바뀌었으면 주 모니터로."""
if not screens:
return None
for screen in screens:
if screen.index == index:
return screen
for screen in screens:
if screen.is_primary:
return screen
return screens[0]
def list_screens() -> list[ScreenInfo]:
"""현재 연결된 모니터 목록 (Qt 필요)."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
return []
primary = app.primaryScreen()
out: list[ScreenInfo] = []
for index, screen in enumerate(app.screens()):
geo = screen.geometry()
out.append(
ScreenInfo(
index=index,
name=screen.name() or f"모니터 {index + 1}",
x=geo.x(),
y=geo.y(),
width=geo.width(),
height=geo.height(),
is_primary=screen is primary,
)
)
return out

View File

@@ -75,7 +75,8 @@ def stylesheet(theme: str = "dark") -> str:
}} }}
/* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면 /* 라벨류는 부모(카드/사이드바) 배경이 비쳐야 한다. 지정하지 않으면
QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */ QWidget 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget {{ QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
QWidget#Row {{
background: transparent; background: transparent;
}} }}
QLabel#Title {{ font-size: 22px; font-weight: 700; }} QLabel#Title {{ font-size: 22px; font-weight: 700; }}

View File

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

View File

@@ -0,0 +1,69 @@
"""디스코드 오버레이식 9분할 위치 선택기.
모니터 모양의 사각형 안에 3x3 버튼을 놓고, 누르면 자막이 그 자리로 붙는다.
좌표를 숫자로 입력하게 하는 것보다 훨씬 직관적이다.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QButtonGroup, QFrame, QGridLayout, QPushButton, QWidget
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, ANCHORS
from ..theme import palette
GRID_ORDER = [
["top-left", "top-center", "top-right"],
["middle-left", "middle-center", "middle-right"],
["bottom-left", "bottom-center", "bottom-right"],
]
class AnchorGrid(QFrame):
"""3x3 위치 버튼. 선택된 칸이 강조된다."""
anchor_selected = Signal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("Card")
self.setFixedHeight(190)
p = palette("dark")
layout = QGridLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(6)
self._buttons: dict[str, QPushButton] = {}
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for row, keys in enumerate(GRID_ORDER):
for col, key in enumerate(keys):
button = QPushButton()
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip(ANCHOR_LABELS[key])
button.setMinimumHeight(42)
button.setStyleSheet(
f"QPushButton {{ background: {p.surface_alt};"
f"border: 1px solid {p.border}; border-radius: 6px; }}"
f"QPushButton:hover {{ border-color: {p.accent}; }}"
f"QPushButton:checked {{ background: {p.accent};"
f"border-color: {p.accent}; }}"
)
button.clicked.connect(lambda _, k=key: self.anchor_selected.emit(k))
self._group.addButton(button)
self._buttons[key] = button
layout.addWidget(button, row, col)
def set_anchor(self, anchor: str) -> None:
"""현재 선택 표시. 자유 배치(custom)면 아무 칸도 켜지 않는다."""
self._group.setExclusive(False)
for key, button in self._buttons.items():
button.setChecked(anchor != ANCHOR_CUSTOM and key == anchor)
self._group.setExclusive(True)
@staticmethod
def is_valid(anchor: str) -> bool:
return anchor in ANCHORS

View File

@@ -42,6 +42,10 @@ class Card(QFrame):
outer.addLayout(self.body) outer.addLayout(self.body)
def add(self, widget: QWidget) -> QWidget: def add(self, widget: QWidget) -> QWidget:
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
if type(widget) is QWidget:
widget.setObjectName("Row")
self.body.addWidget(widget) self.body.addWidget(widget)
return widget return widget
@@ -126,6 +130,7 @@ class LevelMeter(QWidget):
def labelled_row(label: str, widget: QWidget) -> QWidget: def labelled_row(label: str, widget: QWidget) -> QWidget:
row = QWidget() row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row) layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10) layout.setSpacing(10)

View File

@@ -1,7 +1,7 @@
import json import json
from hearo.config import AppConfig from livesub.config import AppConfig
from hearo.models.tiers import DEFAULT_TIER from livesub.models.tiers import DEFAULT_TIER
def test_defaults_are_sane(): def test_defaults_are_sane():

View File

@@ -1,4 +1,4 @@
from hearo.models.glossary import Glossary, GlossaryEntry from livesub.models.glossary import Glossary, GlossaryEntry
def make() -> Glossary: def make() -> Glossary:

92
tests/test_packs.py Normal file
View File

@@ -0,0 +1,92 @@
"""기본 제공 게임 용어집 팩."""
from __future__ import annotations
from livesub.constants import LANGUAGE_CODES
from livesub.models.glossary import Glossary, GlossaryEntry
from livesub.models.packs import available_packs, build_glossary, get_pack
EXPECTED_PACKS = {"lol", "valorant", "overwatch", "fps-common", "minecraft", "streaming"}
def test_bundled_packs_are_loadable():
keys = {p.key for p in available_packs()}
assert keys >= EXPECTED_PACKS, f"빠진 팩: {EXPECTED_PACKS - keys}"
def test_every_pack_has_entries_and_metadata():
for pack in available_packs():
assert pack.name, pack.key
assert pack.description, pack.key
assert len(pack) >= 30, f"{pack.key} 는 용어가 너무 적습니다 ({len(pack)}개)"
def test_every_entry_has_a_korean_target():
"""주 용도가 '외국어 → 한국어' 이므로 한국어 역어는 반드시 있어야 한다."""
for pack in available_packs():
for entry in pack.entries:
assert entry.target_for("ko"), f"{pack.key}: {entry.source} 에 한국어 역어 없음"
def test_targets_only_use_supported_languages():
for pack in available_packs():
for entry in pack.entries:
unknown = set(entry.targets) - set(LANGUAGE_CODES)
assert not unknown, f"{pack.key}: {entry.source} 에 미지원 언어 {unknown}"
def test_no_duplicate_sources_inside_a_pack():
for pack in available_packs():
seen = [e.source.casefold() for e in pack.entries]
dupes = {s for s in seen if seen.count(s) > 1}
assert not dupes, f"{pack.key} 에 중복 용어: {dupes}"
def test_known_terms_are_present():
lol = get_pack("lol")
sources = {e.source.casefold() for e in lol.entries}
assert {"baron", "gank", "nexus", "ult"} <= sources
valorant = get_pack("valorant")
sources = {e.source.casefold() for e in valorant.entries}
assert {"spike", "defuse", "eco", "retake"} <= sources
def test_get_pack_returns_none_for_unknown():
assert get_pack("no-such-game") is None
def test_build_glossary_merges_packs():
merged = build_glossary(Glossary(), ["lol"])
protected, repl = merged.protect("push to the Nexus", "ko")
# 'push' 와 'Nexus' 둘 다 LoL 팩에 있으므로 둘 다 잡힌다.
assert repl == ["푸시", "넥서스"]
assert "Nexus" not in protected
assert Glossary.restore(protected, repl) == "푸시 to the 넥서스"
def test_user_entry_beats_pack_entry():
"""기본 팩 역어가 마음에 안 들면 직접 등록한 것이 이겨야 한다."""
user = Glossary([GlossaryEntry("baron", {"ko": "바론둥이"})])
merged = build_glossary(user, ["lol"])
_, repl = merged.protect("baron", "ko")
assert repl == ["바론둥이"]
def test_unknown_pack_key_is_ignored_not_fatal():
merged = build_glossary(Glossary(), ["lol", "does-not-exist"])
assert len(merged) > 0
def test_multiple_packs_combine():
only_lol = build_glossary(Glossary(), ["lol"])
both = build_glossary(Glossary(), ["lol", "minecraft"])
assert len(both) > len(only_lol)
_, repl = both.protect("creeper", "ko")
assert repl == ["크리퍼"]
def test_no_packs_means_only_user_entries():
user = Glossary([GlossaryEntry("foo", {"ko": "푸"})])
merged = build_glossary(user, [])
assert len(merged) == 1

View File

@@ -7,11 +7,11 @@ import time
import numpy as np import numpy as np
import pytest import pytest
from hearo.audio.base import CaptureBackend, to_mono_16k from livesub.audio.base import CaptureBackend, to_mono_16k
from hearo.config import AppConfig from livesub.config import AppConfig
from hearo.constants import SAMPLE_RATE from livesub.constants import SAMPLE_RATE
from hearo.core.engine import TranslationEngine from livesub.core.engine import TranslationEngine
from hearo.models.asr import Transcript from livesub.models.asr import Transcript
class FakeCapture(CaptureBackend): class FakeCapture(CaptureBackend):
@@ -85,7 +85,7 @@ def engine(tmp_path, monkeypatch):
eng = TranslationEngine(config, on_line=lines.append) eng = TranslationEngine(config, on_line=lines.append)
monkeypatch.setattr(eng.models, "recognizer", lambda: FakeRecognizer()) monkeypatch.setattr(eng.models, "recognizer", lambda: FakeRecognizer())
monkeypatch.setattr(eng.models, "translator", lambda: FakeTranslator()) monkeypatch.setattr(eng.models, "translator", lambda: FakeTranslator())
monkeypatch.setattr("hearo.core.engine.create_capture", lambda cfg: FakeCapture()) monkeypatch.setattr("livesub.core.engine.create_capture", lambda cfg: FakeCapture())
eng.lines = lines eng.lines = lines
yield eng yield eng
eng.shutdown() eng.shutdown()

108
tests/test_placement.py Normal file
View File

@@ -0,0 +1,108 @@
"""자막 창 배치 계산 (모니터 선택 + 디스코드식 9분할)."""
from __future__ import annotations
import pytest
from livesub.ui.placement import (
ANCHOR_LABELS,
ANCHORS,
DEFAULT_ANCHOR,
ScreenInfo,
pick_screen,
resolve_rect,
screen_for_point,
)
MAIN = ScreenInfo(0, "DELL U2720Q", 0, 0, 1920, 1080, is_primary=True)
#: 오른쪽에 붙은 두 번째 모니터 (음수 좌표가 아닌 일반 배치)
SECOND = ScreenInfo(1, "LG 27GP", 1920, 0, 2560, 1440)
#: 왼쪽에 붙어 x 가 음수인 모니터 — 윈도우에서 흔한 구성
LEFT = ScreenInfo(1, "왼쪽", -1600, 0, 1600, 900)
SCREENS = [MAIN, SECOND]
def test_all_nine_anchors_defined_and_labelled():
assert len(ANCHORS) == 9
assert set(ANCHORS) == set(ANCHOR_LABELS)
def test_bottom_center_is_default():
x, y, w, h = resolve_rect(MAIN, DEFAULT_ANCHOR, 1100, 200, margin=60)
assert x == (1920 - 1100) // 2
assert y == 1080 - 200 - 60
def test_each_corner_lands_in_its_corner():
w, h, m = 400, 150, 50
tl = resolve_rect(MAIN, "top-left", w, h, m)
tr = resolve_rect(MAIN, "top-right", w, h, m)
bl = resolve_rect(MAIN, "bottom-left", w, h, m)
br = resolve_rect(MAIN, "bottom-right", w, h, m)
assert tl[0] == m and tl[1] == m
assert tr[0] == 1920 - w - m and tr[1] == m
assert bl[0] == m and bl[1] == 1080 - h - m
assert br[0] == 1920 - w - m and br[1] == 1080 - h - m
def test_middle_center_is_centered():
x, y, w, h = resolve_rect(MAIN, "middle-center", 600, 200, 60)
assert x == (1920 - 600) // 2
assert y == (1080 - 200) // 2
def test_second_monitor_offsets_are_applied():
"""두 번째 모니터는 가상 데스크톱 좌표가 1920부터 시작한다."""
x, y, w, h = resolve_rect(SECOND, "bottom-center", 1100, 200, 60)
assert x == 1920 + (2560 - 1100) // 2
assert y == 1440 - 200 - 60
def test_negative_origin_monitor():
x, y, w, h = resolve_rect(LEFT, "top-left", 400, 150, 50)
assert x == -1600 + 50
assert y == 50
def test_oversized_window_is_clamped_into_the_screen():
"""창이 모니터보다 크면 화면 밖으로 나가면 안 된다."""
x, y, w, h = resolve_rect(LEFT, "bottom-center", 9999, 9999, margin=60)
assert w <= LEFT.width
assert h <= LEFT.height
assert x >= LEFT.x
assert y >= LEFT.y
assert x + w <= LEFT.x + LEFT.width
assert y + h <= LEFT.y + LEFT.height
@pytest.mark.parametrize("anchor", list(ANCHORS))
def test_every_anchor_stays_inside_the_screen(anchor):
x, y, w, h = resolve_rect(SECOND, anchor, 1100, 200, 60)
assert SECOND.x <= x and x + w <= SECOND.x + SECOND.width
assert SECOND.y <= y and y + h <= SECOND.y + SECOND.height
def test_unknown_anchor_falls_back_to_default():
assert resolve_rect(MAIN, "nope", 500, 200, 60) == resolve_rect(
MAIN, DEFAULT_ANCHOR, 500, 200, 60
)
def test_screen_for_point_finds_the_right_monitor():
assert screen_for_point(SCREENS, 100, 100) is MAIN
assert screen_for_point(SCREENS, 2500, 700) is SECOND
assert screen_for_point(SCREENS, 99_999, 0) is None
def test_pick_screen_falls_back_to_primary_when_monitor_disappears():
"""모니터를 뽑았을 때 저장된 인덱스가 사라져도 자막이 실종되면 안 된다."""
assert pick_screen(SCREENS, 1) is SECOND
assert pick_screen(SCREENS, 7) is MAIN # 없는 인덱스 → 주 모니터
assert pick_screen([], 0) is None
def test_pick_screen_uses_first_when_no_primary_flagged():
screens = [ScreenInfo(3, "A", 0, 0, 800, 600), ScreenInfo(4, "B", 800, 0, 800, 600)]
assert pick_screen(screens, 99) is screens[0]

View File

@@ -9,10 +9,10 @@ from __future__ import annotations
import pytest import pytest
from hearo.constants import LANGUAGE_CODES, LANGUAGES from livesub.constants import LANGUAGE_CODES, LANGUAGES
from hearo.models.glossary import Glossary, GlossaryEntry from livesub.models.glossary import Glossary, GlossaryEntry
from hearo.models.tiers import TIERS, MTBackend, PromptStyle, get_tier from livesub.models.tiers import TIERS, MTBackend, PromptStyle, get_tier
from hearo.models.translator import build_seedx_prompt from livesub.models.translator import build_seedx_prompt
def test_seedx_prompt_matches_model_card_exactly(): def test_seedx_prompt_matches_model_card_exactly():

View File

@@ -1,7 +1,7 @@
import numpy as np import numpy as np
from hearo.audio.segmenter import Segmenter, SegmenterConfig from livesub.audio.segmenter import Segmenter, SegmenterConfig
from hearo.constants import SAMPLE_RATE from livesub.constants import SAMPLE_RATE
def tone(ms: int, amplitude: float = 0.3) -> np.ndarray: def tone(ms: int, amplitude: float = 0.3) -> np.ndarray:

View File

@@ -1,4 +1,4 @@
from hearo.models.tiers import ( from livesub.models.tiers import (
DEFAULT_TIER, DEFAULT_TIER,
TIERS, TIERS,
MTBackend, MTBackend,

View File

@@ -12,10 +12,12 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QImage, QPainter # noqa: E402 from PySide6.QtGui import QImage, QPainter # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402
from hearo.config import AppConfig # noqa: E402 from livesub.config import AppConfig # noqa: E402
from hearo.core.events import EngineState, EngineStatus, TranslationLine # noqa: E402 from livesub.core.events import EngineState, EngineStatus, TranslationLine # noqa: E402
from hearo.ui.main_window import MainWindow # noqa: E402 from livesub.models import ModelManager # noqa: E402
from hearo.ui.overlay import paint_subtitle # noqa: E402 from livesub.ui.main_window import MainWindow # noqa: E402
from livesub.ui.overlay import paint_subtitle # noqa: E402
from livesub.ui.placement import ANCHOR_CUSTOM, list_screens, resolve_rect # noqa: E402
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -26,7 +28,7 @@ def qapp():
@pytest.fixture @pytest.fixture
def window(qapp, tmp_path, monkeypatch): def window(qapp, tmp_path, monkeypatch):
monkeypatch.setattr("hearo.constants.user_data_dir", lambda: tmp_path) monkeypatch.setattr("livesub.constants.user_data_dir", lambda: tmp_path)
config = AppConfig() config = AppConfig()
config.models.preload_on_start = False config.models.preload_on_start = False
config.glossary.path = str(tmp_path / "glossary.json") config.glossary.path = str(tmp_path / "glossary.json")
@@ -79,6 +81,82 @@ def test_subtitle_style_edit_propagates(window):
assert window.config.subtitle.font_size == 60 assert window.config.subtitle.font_size == 60
def test_subtitle_toggle_turns_overlay_on_and_off(window):
"""자막 on/off — 단축키와 체크박스가 같은 경로를 탄다."""
window.config.overlay.visible = True
window.overlay.set_subtitle_visible(True)
window.toggle_subtitle()
assert window.config.overlay.visible is False
assert window.overlay.isVisible() is False
# 체크박스가 따라와야 한다 (시그널 연결 확인)
assert window.subtitle_page.overlay_check.isChecked() is False
window.toggle_subtitle()
assert window.config.overlay.visible is True
assert window.subtitle_page.overlay_check.isChecked() is True
def test_turning_subtitle_off_clears_leftover_text(window):
window.config.log_transcripts = False
window.overlay.set_subtitle_visible(True)
window._on_line(TranslationLine("hello", "안녕", "en", "ko", is_final=True))
assert window.overlay._final_text
window.toggle_subtitle()
assert window.overlay._final_text == ""
def test_checkbox_and_hotkey_do_not_fight_each_other(window):
"""체크박스 → 오버레이 → 체크박스 되먹임이 무한루프가 되면 안 된다."""
window.config.overlay.visible = True
window.overlay.set_subtitle_visible(True)
window.subtitle_page.overlay_check.setChecked(False)
assert window.config.overlay.visible is False
assert window.overlay.isVisible() is False
def test_anchor_selection_moves_the_overlay(window):
window.subtitle_page._on_anchor_selected("top-left")
assert window.config.overlay.anchor == "top-left"
screens = list_screens()
if screens: # offscreen 플랫폼에도 가상 화면이 하나 있다
expected = resolve_rect(
screens[0], "top-left",
window.config.overlay.width, window.config.overlay.height,
window.config.overlay.margin,
)
assert (window.config.overlay.x, window.config.overlay.y) == expected[:2]
def test_dragging_switches_to_custom_placement(window):
window.subtitle_page._on_anchor_selected("bottom-center")
assert window.config.overlay.anchor == "bottom-center"
window.overlay.move(123, 456)
window.overlay._drag_from = window.overlay.pos()
window.overlay.mouseReleaseEvent(None)
assert window.config.overlay.anchor == ANCHOR_CUSTOM
def test_glossary_packs_reach_the_engine(window):
window.config.glossary.enabled_packs = ["lol"]
window._on_glossary_changed()
_, repl = window.engine.glossary.protect("the Nexus", "ko")
assert repl == ["넥서스"]
def test_game_mode_lowers_precision(window):
window.config.performance.game_mode = True
manager = ModelManager(window.config.models, low_power=True)
assert "int8" in manager.effective_asr_spec().compute_type
assert manager.effective_asr_spec().beam_size == 1
normal = ModelManager(window.config.models, low_power=False)
assert normal.effective_asr_spec() == normal.tier.asr
def test_paint_subtitle_actually_draws_pixels(qapp): def test_paint_subtitle_actually_draws_pixels(qapp):
"""자막 글씨가 투명 이미지 위에 실제로 그려지는지 (빈 화면이면 실패). """자막 글씨가 투명 이미지 위에 실제로 그려지는지 (빈 화면이면 실패).