diff --git a/README.md b/README.md index c0acdd0..7a0dbf0 100644 --- a/README.md +++ b/README.md @@ -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)에서는 어떤 오버레이도 표시되지 않습니다. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ddbd859..eeedd2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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()` 하나를 공유한다. diff --git a/docs/FINETUNING.md b/docs/FINETUNING.md index 79162ba..7de304f 100644 --- a/docs/FINETUNING.md +++ b/docs/FINETUNING.md @@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好, 1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다. 2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬. 3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면 - `%APPDATA%/Hearo/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. + `%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. **틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다. 4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로. diff --git a/docs/images/glossary.png b/docs/images/glossary.png index 4686285..07d128e 100644 Binary files a/docs/images/glossary.png and b/docs/images/glossary.png differ diff --git a/docs/images/home.png b/docs/images/home.png index 655bb16..e5507c9 100644 Binary files a/docs/images/home.png and b/docs/images/home.png differ diff --git a/docs/images/models.png b/docs/images/models.png index 84636a4..25ad937 100644 Binary files a/docs/images/models.png and b/docs/images/models.png differ diff --git a/docs/images/overlay.png b/docs/images/overlay.png index 2cba756..f1f54b6 100644 Binary files a/docs/images/overlay.png and b/docs/images/overlay.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png new file mode 100644 index 0000000..62f3b72 Binary files /dev/null and b/docs/images/settings.png differ diff --git a/docs/images/subtitle.png b/docs/images/subtitle.png index 3faf3ad..06601c5 100644 Binary files a/docs/images/subtitle.png and b/docs/images/subtitle.png differ diff --git a/native/process_loopback/CMakeLists.txt b/native/process_loopback/CMakeLists.txt index e15dfff..bc6d543 100644 --- a/native/process_loopback/CMakeLists.txt +++ b/native/process_loopback/CMakeLists.txt @@ -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 - "$" - "${CMAKE_SOURCE_DIR}/../../src/hearo/resources/bin/") + "$" + "${CMAKE_SOURCE_DIR}/../../src/livesub/resources/bin/") diff --git a/native/process_loopback/build.ps1 b/native/process_loopback/build.ps1 index 7b99a44..520794e 100644 --- a/native/process_loopback/build.ps1 +++ b/native/process_loopback/build.ps1 @@ -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 { diff --git a/native/process_loopback/main.cpp b/native/process_loopback/main.cpp index 4cbc99a..30c2758 100644 --- a/native/process_loopback/main.cpp +++ b/native/process_loopback/main.cpp @@ -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(hr)); + std::fprintf(stderr, "[livesub_capture] %s (hr=0x%08lX)\n", what, static_cast(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(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 [--exclude]\n"); + std::fprintf(stderr, "사용법: livesub_capture.exe --pid [--exclude]\n"); return 64; } diff --git a/pyproject.toml b/pyproject.toml index 98e59fc..ecfbc12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/finetune_mt.py b/scripts/finetune_mt.py index 1c32eaa..70410d4 100644 --- a/scripts/finetune_mt.py +++ b/scripts/finetune_mt.py @@ -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"] diff --git a/src/hearo/ui/pages/settings_page.py b/src/hearo/ui/pages/settings_page.py deleted file mode 100644 index 9432832..0000000 --- a/src/hearo/ui/pages/settings_page.py +++ /dev/null @@ -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 diff --git a/src/hearo/ui/widgets/__init__.py b/src/hearo/ui/widgets/__init__.py deleted file mode 100644 index d9644f8..0000000 --- a/src/hearo/ui/widgets/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .common import Card, LevelMeter, PageHeader, StatusPill, labelled_row - -__all__ = ["Card", "LevelMeter", "PageHeader", "StatusPill", "labelled_row"] diff --git a/src/hearo/__init__.py b/src/livesub/__init__.py similarity index 58% rename from src/hearo/__init__.py rename to src/livesub/__init__.py index 7c1194e..114e182 100644 --- a/src/hearo/__init__.py +++ b/src/livesub/__init__.py @@ -1,4 +1,4 @@ -"""Hearo — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구.""" +"""LiveSub — 프로그램 소리를 실시간으로 듣고 번역해 자막으로 보여주는 도구.""" from .constants import APP_NAME, APP_SLOGAN, APP_VERSION diff --git a/src/hearo/__main__.py b/src/livesub/__main__.py similarity index 100% rename from src/hearo/__main__.py rename to src/livesub/__main__.py diff --git a/src/hearo/app.py b/src/livesub/app.py similarity index 97% rename from src/hearo/app.py rename to src/livesub/app.py index 3a8f4c4..f92b7b8 100644 --- a/src/hearo/app.py +++ b/src/livesub/app.py @@ -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")) diff --git a/src/hearo/audio/__init__.py b/src/livesub/audio/__init__.py similarity index 96% rename from src/hearo/audio/__init__.py rename to src/livesub/audio/__init__.py index 412989e..4dc70c7 100644 --- a/src/hearo/audio/__init__.py +++ b/src/livesub/audio/__init__.py @@ -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("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.") diff --git a/src/hearo/audio/base.py b/src/livesub/audio/base.py similarity index 100% rename from src/hearo/audio/base.py rename to src/livesub/audio/base.py diff --git a/src/hearo/audio/file_source.py b/src/livesub/audio/file_source.py similarity index 100% rename from src/hearo/audio/file_source.py rename to src/livesub/audio/file_source.py diff --git a/src/hearo/audio/process_loopback.py b/src/livesub/audio/process_loopback.py similarity index 97% rename from src/hearo/audio/process_loopback.py rename to src/livesub/audio/process_loopback.py index 31ab0e0..087cf83 100644 --- a/src/hearo/audio/process_loopback.py +++ b/src/livesub/audio/process_loopback.py @@ -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 분량 diff --git a/src/hearo/audio/segmenter.py b/src/livesub/audio/segmenter.py similarity index 100% rename from src/hearo/audio/segmenter.py rename to src/livesub/audio/segmenter.py diff --git a/src/hearo/audio/wasapi_loopback.py b/src/livesub/audio/wasapi_loopback.py similarity index 100% rename from src/hearo/audio/wasapi_loopback.py rename to src/livesub/audio/wasapi_loopback.py diff --git a/src/hearo/config.py b/src/livesub/config.py similarity index 68% rename from src/hearo/config.py rename to src/livesub/config.py index a99c0b5..219eac0 100644 --- a/src/hearo/config.py +++ b/src/livesub/config.py @@ -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 diff --git a/src/hearo/constants.py b/src/livesub/constants.py similarity index 85% rename from src/hearo/constants.py rename to src/livesub/constants.py index 9ee7b02..925dadc 100644 --- a/src/hearo/constants.py +++ b/src/livesub/constants.py @@ -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 diff --git a/src/hearo/core/__init__.py b/src/livesub/core/__init__.py similarity index 100% rename from src/hearo/core/__init__.py rename to src/livesub/core/__init__.py diff --git a/src/hearo/core/engine.py b/src/livesub/core/engine.py similarity index 73% rename from src/hearo/core/engine.py rename to src/livesub/core/engine.py index f569f05..3d80dd3 100644 --- a/src/hearo/core/engine.py +++ b/src/livesub/core/engine.py @@ -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(): diff --git a/src/hearo/core/events.py b/src/livesub/core/events.py similarity index 100% rename from src/hearo/core/events.py rename to src/livesub/core/events.py diff --git a/src/livesub/resources/glossaries/fps-common.json b/src/livesub/resources/glossaries/fps-common.json new file mode 100644 index 0000000..87c51e5 --- /dev/null +++ b/src/livesub/resources/glossaries/fps-common.json @@ -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": "我的错"}} + ] +} diff --git a/src/livesub/resources/glossaries/lol.json b/src/livesub/resources/glossaries/lol.json new file mode 100644 index 0000000..37b95bc --- /dev/null +++ b/src/livesub/resources/glossaries/lol.json @@ -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": "满装备"}} + ] +} diff --git a/src/livesub/resources/glossaries/minecraft.json b/src/livesub/resources/glossaries/minecraft.json new file mode 100644 index 0000000..3ebd9e8 --- /dev/null +++ b/src/livesub/resources/glossaries/minecraft.json @@ -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": "农场"}} + ] +} diff --git a/src/livesub/resources/glossaries/overwatch.json b/src/livesub/resources/glossaries/overwatch.json new file mode 100644 index 0000000..76ec41e --- /dev/null +++ b/src/livesub/resources/glossaries/overwatch.json @@ -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": "推进"}} + ] +} diff --git a/src/livesub/resources/glossaries/streaming.json b/src/livesub/resources/glossaries/streaming.json new file mode 100644 index 0000000..27a1b7f --- /dev/null +++ b/src/livesub/resources/glossaries/streaming.json @@ -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": "欢迎回来"}} + ] +} diff --git a/src/livesub/resources/glossaries/valorant.json b/src/livesub/resources/glossaries/valorant.json new file mode 100644 index 0000000..133eb56 --- /dev/null +++ b/src/livesub/resources/glossaries/valorant.json @@ -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": "赛点"}} + ] +} diff --git a/src/hearo/ui/__init__.py b/src/livesub/ui/__init__.py similarity index 100% rename from src/hearo/ui/__init__.py rename to src/livesub/ui/__init__.py diff --git a/src/livesub/ui/hotkeys.py b/src/livesub/ui/hotkeys.py new file mode 100644 index 0000000..55460ac --- /dev/null +++ b/src/livesub/ui/hotkeys.py @@ -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 diff --git a/src/hearo/ui/main_window.py b/src/livesub/ui/main_window.py similarity index 73% rename from src/hearo/ui/main_window.py rename to src/livesub/ui/main_window.py index 40ec4ef..2c092e2 100644 --- a/src/hearo/ui/main_window.py +++ b/src/livesub/ui/main_window.py @@ -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() diff --git a/src/hearo/ui/overlay.py b/src/livesub/ui/overlay.py similarity index 81% rename from src/hearo/ui/overlay.py rename to src/livesub/ui/overlay.py index 5470b82..cf946df 100644 --- a/src/hearo/ui/overlay.py +++ b/src/livesub/ui/overlay.py @@ -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,13 +291,22 @@ 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: - self._drag_from = None - self._resize_from = None - g = self.geometry() - self.overlay_cfg.x, self.overlay_cfg.y = g.x(), g.y() - self.overlay_cfg.width, self.overlay_cfg.height = g.width(), g.height() - self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height()) + 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() + 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 if self.overlay_cfg.locked: @@ -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: diff --git a/src/hearo/ui/pages/__init__.py b/src/livesub/ui/pages/__init__.py similarity index 100% rename from src/hearo/ui/pages/__init__.py rename to src/livesub/ui/pages/__init__.py diff --git a/src/hearo/ui/pages/glossary_page.py b/src/livesub/ui/pages/glossary_page.py similarity index 84% rename from src/hearo/ui/pages/glossary_page.py rename to src/livesub/ui/pages/glossary_page.py index b201011..0f1fcf1 100644 --- a/src/hearo/ui/pages/glossary_page.py +++ b/src/livesub/ui/pages/glossary_page.py @@ -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() diff --git a/src/hearo/ui/pages/home.py b/src/livesub/ui/pages/home.py similarity index 98% rename from src/hearo/ui/pages/home.py rename to src/livesub/ui/pages/home.py index 12f0fd3..9b3b0bf 100644 --- a/src/hearo/ui/pages/home.py +++ b/src/livesub/ui/pages/home.py @@ -146,7 +146,7 @@ class HomePage(QWidget): msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다." elif caps["device"]: msg = ( - "프로그램별 캡처 보조 프로그램(hearo_capture.exe)이 없어 " + "프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 " "출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 " "프로그램 단위로 분리됩니다." ) diff --git a/src/hearo/ui/pages/models_page.py b/src/livesub/ui/pages/models_page.py similarity index 100% rename from src/hearo/ui/pages/models_page.py rename to src/livesub/ui/pages/models_page.py diff --git a/src/livesub/ui/pages/settings_page.py b/src/livesub/ui/pages/settings_page.py new file mode 100644 index 0000000..e31e450 --- /dev/null +++ b/src/livesub/ui/pages/settings_page.py @@ -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 diff --git a/src/hearo/ui/pages/subtitle_page.py b/src/livesub/ui/pages/subtitle_page.py similarity index 55% rename from src/hearo/ui/pages/subtitle_page.py rename to src/livesub/ui/pages/subtitle_page.py index f70d9dd..0ac0e03 100644 --- a/src/hearo/ui/pages/subtitle_page.py +++ b/src/livesub/ui/pages/subtitle_page.py @@ -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() diff --git a/src/livesub/ui/placement.py b/src/livesub/ui/placement.py new file mode 100644 index 0000000..000251a --- /dev/null +++ b/src/livesub/ui/placement.py @@ -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 diff --git a/src/hearo/ui/theme.py b/src/livesub/ui/theme.py similarity index 99% rename from src/hearo/ui/theme.py rename to src/livesub/ui/theme.py index a0d6eb4..7747543 100644 --- a/src/hearo/ui/theme.py +++ b/src/livesub/ui/theme.py @@ -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; }} diff --git a/src/livesub/ui/widgets/__init__.py b/src/livesub/ui/widgets/__init__.py new file mode 100644 index 0000000..48159af --- /dev/null +++ b/src/livesub/ui/widgets/__init__.py @@ -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"] diff --git a/src/livesub/ui/widgets/anchor_grid.py b/src/livesub/ui/widgets/anchor_grid.py new file mode 100644 index 0000000..bfb9a24 --- /dev/null +++ b/src/livesub/ui/widgets/anchor_grid.py @@ -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 diff --git a/src/hearo/ui/widgets/common.py b/src/livesub/ui/widgets/common.py similarity index 93% rename from src/hearo/ui/widgets/common.py rename to src/livesub/ui/widgets/common.py index 27796e9..9c99a77 100644 --- a/src/hearo/ui/widgets/common.py +++ b/src/livesub/ui/widgets/common.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index 6548d06..e43ad14 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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(): diff --git a/tests/test_glossary.py b/tests/test_glossary.py index 1662f01..3dc601c 100644 --- a/tests/test_glossary.py +++ b/tests/test_glossary.py @@ -1,4 +1,4 @@ -from hearo.models.glossary import Glossary, GlossaryEntry +from livesub.models.glossary import Glossary, GlossaryEntry def make() -> Glossary: diff --git a/tests/test_packs.py b/tests/test_packs.py new file mode 100644 index 0000000..7969cd8 --- /dev/null +++ b/tests/test_packs.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index fab33c1..24f9a6f 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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() diff --git a/tests/test_placement.py b/tests/test_placement.py new file mode 100644 index 0000000..d5af270 --- /dev/null +++ b/tests/test_placement.py @@ -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] diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 478d56c..a2d4ca6 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -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(): diff --git a/tests/test_segmenter.py b/tests/test_segmenter.py index 973bda2..75b61ff 100644 --- a/tests/test_segmenter.py +++ b/tests/test_segmenter.py @@ -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: diff --git a/tests/test_tiers.py b/tests/test_tiers.py index 4139b29..73962d6 100644 --- a/tests/test_tiers.py +++ b/tests/test_tiers.py @@ -1,4 +1,4 @@ -from hearo.models.tiers import ( +from livesub.models.tiers import ( DEFAULT_TIER, TIERS, MTBackend, diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index ea22275..ca20a1f 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -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): """자막 글씨가 투명 이미지 위에 실제로 그려지는지 (빈 화면이면 실패).