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 · 日本語 · 中文 사이를 번역합니다.
![홈 화면](docs/images/home.png)
![자막 오버레이](docs/images/overlay.png)
---
## 무엇을 하는 프로그램인가
게임을 하는데 영어 음성만 나올 때, 해외 방송을 보는데 자막이 없을 때,
그 프로그램을 골라서 `번역 시작`만 누르면 화면 아래에 한국어 자막이 뜹니다.
그 프로그램을 골라서 `번역 시작`만 누르면 화면에 한국어 자막이 뜹니다.
```
게임 소리 → 음성인식 → 번역 → 화면 위 자막
(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 학습시킬 수 있습니다
- **완전 로컬** — 음성이 외부로 나가지 않습니다
---
## 게임과 같이 쓰기 (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. 사전 준비
@@ -74,22 +112,28 @@ powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
### 5. 실행
```powershell
hearo
livesub
```
또는 `python -m hearo`
또는 `python -m livesub`
---
## 처음 쓸 때
1. **모델** 화면에서 티어를 고릅니다. VRAM 8GB면 `3. 균형`, 10GB 이상이면 `4. 정밀`
2. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
3. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
4. `번역 시작`
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
4. **자막** 화면에서 모니터와 위치를 정합니다
5. `번역 시작` — 또는 게임 중에 `Ctrl+Alt+D`
모델 티어 기본값은 `2. 신속`입니다. 게임과 같이 돌리기 좋은 지점이라 그렇게 잡았고,
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 `%APPDATA%\Hearo\models` 입니다.
저장 위치는 `%APPDATA%\LiveSub\models` 입니다.
![모델 화면](docs/images/models.png)
---
@@ -98,13 +142,11 @@ hearo
| # | 이름 | VRAM | 지연 | 설명 |
|---|---|---|---|---|
| 1 | 번개 | 2GB | 0.6초 | 속도 최우선. 저사양 GPU |
| 2 | 신속 | 4GB | 0.9초 | 인식률은 최상급, 번역만 경량 |
| 3 | **균형** | 6GB | 1.2초 | 기본값. 8GB GPU에 가장 무난 |
| 2 | **신속** | 4GB | 0.9초 | **기본값.** 인식률은 최상급, 번역만 경량 |
| 3 | 균형 | 6GB | 1.2초 | 게임을 안 켜고 볼 때 무난 |
| 4 | **정밀** | 10GB | 2.0초 | **추천.** 현 시점 최고 번역 품질 |
| 5 | 극한 | 16GB | 3.0초 | 추가학습(LoRA)에 최적 |
![모델 화면](docs/images/models.png)
어떤 모델을 왜 골랐는지는 [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
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
@@ -121,8 +179,6 @@ Nexus,넥서스,Nexus,ネクサス,基地,LoL
baron,바론,Baron,バロン,男爵,LoL
```
게임 하나당 200~500개만 등록해도 체감 품질이 크게 달라집니다.
## 추가학습
말투나 문장 구조까지 바꾸고 싶다면 LoRA로 학습시킬 수 있습니다.
@@ -142,11 +198,15 @@ python scripts\finetune_mt.py --data data\game.jsonl --tier ultimate --output .\
| 동작 | 결과 |
|---|---|
| 드래그 | 위치 이동 |
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 (게임 중에도 동작) |
| 드래그 | 위치 이동 (자유 배치로 전환) |
| 우하단 모서리 드래그 | 크기 조절 |
| 마우스 휠 | 글자 크기 |
| 우클릭 | 잠금 / 클릭 통과 / 항상 위에 / 숨기기 |
클릭 통과는 기본으로 켜져 있어, 자막 위를 클릭해도 게임으로 전달됩니다.
위치를 옮기려면 `자막` 화면에서 클릭 통과를 잠깐 끄세요.
전체화면 게임에서 자막이 안 보이면 게임을 **테두리 없는 창 모드**로 바꾸세요.
독점 전체화면(exclusive fullscreen)에서는 어떤 오버레이도 표시되지 않습니다.

View File

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

View File

@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
`%APPDATA%/Hearo/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
`%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
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)
project(hearo_capture LANGUAGES CXX)
project(livesub_capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(hearo_capture main.cpp)
add_executable(livesub_capture main.cpp)
if(MSVC)
target_compile_options(hearo_capture PRIVATE /W3 /permissive- /EHsc)
target_compile_definitions(hearo_capture PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX)
target_compile_options(livesub_capture PRIVATE /W3 /permissive- /EHsc)
target_compile_definitions(livesub_capture PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX)
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
"${CMAKE_SOURCE_DIR}/../../src/hearo/resources/bin"
"${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:hearo_capture>"
"${CMAKE_SOURCE_DIR}/../../src/hearo/resources/bin/")
"$<TARGET_FILE:livesub_capture>"
"${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 --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) {
Write-Host "빌드 완료: $out" -ForegroundColor Green
} else {

View File

@@ -1,8 +1,8 @@
// hearo_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만
// livesub_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만
// 뽑아내 stdout 으로 흘려보내는 보조 프로그램.
//
// 출력 포맷: 16000Hz / mono / 32-bit float, 헤더 없는 생 PCM.
// 사용법: hearo_capture.exe --pid 1234 [--exclude]
// 사용법: livesub_capture.exe --pid 1234 [--exclude]
// --exclude 를 주면 해당 프로세스 트리를 "제외한" 나머지 소리를 받는다.
//
// Windows 10 2004(build 19041) 이상에서 동작하는 WASAPI process loopback API를
@@ -77,7 +77,7 @@ private:
};
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) {
@@ -156,7 +156,7 @@ int Run(DWORD pid, bool exclude) {
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);
std::fflush(stderr);
@@ -211,7 +211,7 @@ int main(int argc, char** argv) {
}
}
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;
}

View File

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

View File

@@ -20,14 +20,14 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from hearo.constants import LANGUAGES, models_dir # noqa: E402
from hearo.models.tiers import ( # noqa: E402
from livesub.constants import LANGUAGES, models_dir # noqa: E402
from livesub.models.tiers import ( # noqa: E402
MTBackend,
PromptStyle,
get_tier,
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 투영 이름
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

View File

@@ -11,7 +11,7 @@ from .constants import APP_NAME, ORG_NAME, user_data_dir
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)]
with contextlib.suppress(OSError):
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)
if backend == "process":
raise CaptureError(
"프로그램별 캡처를 쓰려면 hearo_capture.exe 가 필요합니다. "
"프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
)
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")

View File

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

View File

@@ -39,14 +39,22 @@ class SubtitleStyle:
class OverlayConfig:
"""자막 창(오버레이) 위치/동작."""
x: int = -1 # -1 = 화면 하단 중앙 자동 배치
x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
y: int = -1
width: int = 1100
height: int = 200
always_on_top: bool = True
click_through: bool = False
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True
locked: bool = False
#: 자막 표시 on/off (단축키로 토글)
visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터)
screen_index: int = 0
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
anchor: str = "bottom-center"
#: 화면 가장자리에서 띄울 간격
margin: int = 60
@dataclass
@@ -79,6 +87,40 @@ class GlossaryConfig:
enabled: bool = True
path: str = "" # 비어있으면 user_data_dir()/glossary.json
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
@@ -88,6 +130,8 @@ class AppConfig:
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
overlay: OverlayConfig = field(default_factory=OverlayConfig)
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
performance: PerformanceConfig = field(default_factory=PerformanceConfig)
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
theme: str = "dark" # dark | light
start_minimized: bool = False
log_transcripts: bool = True

View File

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

View File

@@ -26,6 +26,8 @@ from ..audio import (
from ..config import AppConfig
from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary
from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__)
@@ -45,7 +47,7 @@ class TranslationEngine:
self._on_line = on_line
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._capture: CaptureBackend | None = None
@@ -56,6 +58,9 @@ class TranslationEngine:
self._level = 0.0
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
self._last_final_text = ""
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
self._last_activity = time.monotonic()
self._models_unloaded = False
# --- 수명주기 -------------------------------------------------------
@property
@@ -101,6 +106,10 @@ class TranslationEngine:
# --- 내부 스레드 ----------------------------------------------------
def _bootstrap(self) -> None:
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:
self.models.preload(
lambda msg, pct: self._emit_status(EngineState.LOADING, msg, pct)
@@ -124,12 +133,15 @@ class TranslationEngine:
def _segment_loop(self) -> None:
cfg = self.config.audio
# 중간 결과는 같은 오디오를 여러 번 인식하므로 GPU 작업이 거의 두 배가 된다.
# 게임과 같이 돌릴 때는 그만한 값어치가 없으므로 저부하 모드에서는 끈다.
partial_ms = 0 if self.config.performance.game_mode else cfg.partial_interval_ms
segmenter = Segmenter(
SegmenterConfig(
silence_ms=cfg.silence_ms,
min_segment_ms=cfg.min_segment_ms,
max_segment_ms=cfg.max_segment_ms,
partial_interval_ms=cfg.partial_interval_ms,
partial_interval_ms=partial_ms,
vad_aggressiveness=cfg.vad_aggressiveness,
)
)
@@ -169,25 +181,56 @@ class TranslationEngine:
pass
def _infer_loop(self) -> None:
recognizer = self.models.recognizer()
translator = self.models.translator()
mcfg = self.config.models
perf = self.config.performance
while not self._stop.is_set():
try:
segment = self._segments.get(timeout=0.3)
except queue.Empty:
self._maybe_unload_idle()
continue
# 처리 중 더 새로운 확정 구간이 들어왔다면 오래된 중간 결과는 버린다.
if not segment.is_final and not self._segments.empty():
continue
# 모델 참조를 캐시하지 않는다. 유휴 언로드 뒤에는 새로 올려야 하고,
# 캐시된 참조를 쓰면 이미 내려간 모델을 붙잡고 있게 된다.
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 - 한 문장 실패로 엔진이 죽으면 안 된다
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:
source_lang = None if mcfg.source_lang == "auto" else mcfg.source_lang
@@ -236,9 +279,16 @@ class TranslationEngine:
# --- 유틸 -----------------------------------------------------------
def _load_glossary(self) -> Glossary:
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
cfg = self.config.glossary
path = cfg.path or str(user_data_dir() / "glossary.json")
return Glossary.load(path, cfg.case_sensitive)
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:
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 ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
from .hotkeys import GlobalHotkeys
from .overlay import SubtitleOverlay
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
from .theme import SPACING, palette, stylesheet
@@ -60,10 +61,12 @@ class MainWindow(QMainWindow):
on_status=self.status_received.emit,
)
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
self.hotkeys = GlobalHotkeys(self)
self._transcript_file = None
self._build_ui()
self._connect()
self._install_hotkeys()
self._save_timer = QTimer(self)
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.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.settings_page.config_changed.connect(self._schedule_save)
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(
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:
if self.config.overlay.visible:
@@ -204,8 +259,21 @@ class MainWindow(QMainWindow):
self._schedule_save()
def _on_overlay_toggled(self, visible: bool) -> None:
self.config.overlay.visible = visible
self.overlay.setVisible(visible)
if visible == self.config.overlay.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()
def _on_glossary_changed(self) -> None:
@@ -240,6 +308,7 @@ class MainWindow(QMainWindow):
# --- 종료 -----------------------------------------------------------
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.config.save()
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
self.engine.shutdown()
self._close_transcript()
self.overlay.close()

View File

@@ -22,9 +22,10 @@ from PySide6.QtGui import (
QPainterPath,
QPen,
)
from PySide6.QtWidgets import QApplication, QMenu, QWidget
from PySide6.QtWidgets import QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
GRIP = 18 # 우하단 리사이즈 핸들 크기
@@ -142,6 +143,8 @@ class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
@@ -182,17 +185,45 @@ class SubtitleOverlay(QWidget):
Qt.WidgetAttribute.WA_TransparentForMouseEvents, cfg.click_through
)
if cfg.x < 0 or cfg.y < 0:
screen = QApplication.primaryScreen()
area = screen.availableGeometry() if screen else QRect(0, 0, 1920, 1080)
cfg.width = min(cfg.width, area.width() - 80)
cfg.x = area.x() + (area.width() - cfg.width) // 2
cfg.y = area.y() + area.height() - cfg.height - 90
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
self.snap_to_anchor(save_only=True)
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
if cfg.visible:
self.show()
self.setVisible(cfg.visible)
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:
if is_final:
self._final_text = translated or source
@@ -260,12 +291,21 @@ class SubtitleOverlay(QWidget):
self.move(event.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is not None or self._resize_from is not None:
if self._drag_from is None and self._resize_from is None:
return
self._drag_from = None
self._resize_from = None
cfg = self.overlay_cfg
g = self.geometry()
self.overlay_cfg.x, self.overlay_cfg.y = g.x(), g.y()
self.overlay_cfg.width, self.overlay_cfg.height = g.width(), g.height()
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
@@ -311,8 +351,7 @@ class SubtitleOverlay(QWidget):
self.apply_config()
def hide_overlay(self) -> None:
self.overlay_cfg.visible = False
self.hide()
self.set_subtitle_visible(False)
self.closed.emit()
def _restart_fade(self) -> None:

View File

@@ -25,6 +25,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry
from ...models.packs import available_packs
from ..theme import SPACING
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_table_card(), 1)
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:
card = Card()

View File

@@ -146,7 +146,7 @@ class HomePage(QWidget):
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(hearo_capture.exe)이 없어 "
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. 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 ..overlay import SubtitlePreview
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
from ..theme import SPACING
from ..widgets import Card, PageHeader
from ..widgets import AnchorGrid, Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
@@ -56,7 +57,10 @@ class ColorButton(QPushButton):
class SubtitlePage(QWidget):
style_changed = Signal()
#: 자막 표시 on/off
overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
@@ -66,9 +70,10 @@ class SubtitlePage(QWidget):
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("자막", "자막 창에 표시될 글씨의 모양을 정합니다. 아래에서 바로 확인하세요.")
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
)
root.addWidget(self._build_display_card())
root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card())
@@ -77,6 +82,86 @@ class SubtitlePage(QWidget):
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:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
@@ -140,6 +225,7 @@ class SubtitlePage(QWidget):
form.addRow("배경 색", self.bg_color)
opacity_row = QWidget()
opacity_row.setObjectName("Row")
layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
@@ -180,12 +266,70 @@ class SubtitlePage(QWidget):
self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check)
self.overlay_check = QCheckBox("자막 창 보이기")
self.overlay_check.setChecked(self.config.overlay.visible)
self.overlay_check.toggled.connect(self.overlay_toggled.emit)
form.addRow("", self.overlay_check)
return card
# --- 표시 위치 -------------------------------------------------------
def 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:
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 규칙의 창 배경색을 상속해 카드 위에 검은 박스가 생긴다. */
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget {{
QLabel, QCheckBox, QRadioButton, QScrollArea > QWidget > QWidget,
QWidget#Row {{
background: transparent;
}}
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)
def add(self, widget: QWidget) -> QWidget:
# 레이아웃용 맨 QWidget 은 창 배경색을 상속해 카드 위에 검은 막대가 된다.
# 이름을 달아주면 QSS 가 투명하게 처리한다 (theme.py 의 QWidget#Row).
if type(widget) is QWidget:
widget.setObjectName("Row")
self.body.addWidget(widget)
return widget
@@ -126,6 +130,7 @@ class LevelMeter(QWidget):
def labelled_row(label: str, widget: QWidget) -> QWidget:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)

View File

@@ -1,7 +1,7 @@
import json
from hearo.config import AppConfig
from hearo.models.tiers import DEFAULT_TIER
from livesub.config import AppConfig
from livesub.models.tiers import DEFAULT_TIER
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:

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 pytest
from hearo.audio.base import CaptureBackend, to_mono_16k
from hearo.config import AppConfig
from hearo.constants import SAMPLE_RATE
from hearo.core.engine import TranslationEngine
from hearo.models.asr import Transcript
from livesub.audio.base import CaptureBackend, to_mono_16k
from livesub.config import AppConfig
from livesub.constants import SAMPLE_RATE
from livesub.core.engine import TranslationEngine
from livesub.models.asr import Transcript
class FakeCapture(CaptureBackend):
@@ -85,7 +85,7 @@ def engine(tmp_path, monkeypatch):
eng = TranslationEngine(config, on_line=lines.append)
monkeypatch.setattr(eng.models, "recognizer", lambda: FakeRecognizer())
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
yield eng
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
from hearo.constants import LANGUAGE_CODES, LANGUAGES
from hearo.models.glossary import Glossary, GlossaryEntry
from hearo.models.tiers import TIERS, MTBackend, PromptStyle, get_tier
from hearo.models.translator import build_seedx_prompt
from livesub.constants import LANGUAGE_CODES, LANGUAGES
from livesub.models.glossary import Glossary, GlossaryEntry
from livesub.models.tiers import TIERS, MTBackend, PromptStyle, get_tier
from livesub.models.translator import build_seedx_prompt
def test_seedx_prompt_matches_model_card_exactly():

View File

@@ -1,7 +1,7 @@
import numpy as np
from hearo.audio.segmenter import Segmenter, SegmenterConfig
from hearo.constants import SAMPLE_RATE
from livesub.audio.segmenter import Segmenter, SegmenterConfig
from livesub.constants import SAMPLE_RATE
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,
TIERS,
MTBackend,

View File

@@ -12,10 +12,12 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QImage, QPainter # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from hearo.config import AppConfig # noqa: E402
from hearo.core.events import EngineState, EngineStatus, TranslationLine # noqa: E402
from hearo.ui.main_window import MainWindow # noqa: E402
from hearo.ui.overlay import paint_subtitle # noqa: E402
from livesub.config import AppConfig # noqa: E402
from livesub.core.events import EngineState, EngineStatus, TranslationLine # noqa: E402
from livesub.models import ModelManager # 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")
@@ -26,7 +28,7 @@ def qapp():
@pytest.fixture
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.models.preload_on_start = False
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
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):
"""자막 글씨가 투명 이미지 위에 실제로 그려지는지 (빈 화면이면 실패).