40 Commits

Author SHA1 Message Date
EJClaw
1dde1b2aaa fix: 2~4티어가 존재하지 않는 음성인식 모델 저장소를 가리키던 것
Some checks failed
Windows / verify (push) Has been cancelled
Systran/faster-whisper-large-v3-turbo 는 Hugging Face 에 없는 저장소라
번역 시작 시 401 Repository Not Found 로 바로 실패했다.
faster-whisper 가 'turbo' 로 공식 매핑하는 저장소
(mobiuslabsgmbh → 현재 dropbox-dash/faster-whisper-large-v3-turbo)로 바꾼다.

티어의 모든 ASR/MT 저장소가 로그인 없이 공개돼 있고 가중치가 있는지
확인하는 테스트를 추가했다 (오프라인이면 건너뜀).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:42:25 +09:00
EJClaw
0201c7b913 feat: 자막 배경 여백 · 위치 조정 모드 · CPU 추천 · 마이크 입력
Some checks failed
Windows / verify (push) Has been cancelled
요청 네 가지를 넣는다.

1) 자막 배경 여백(padding)
   배경 상자를 창 전체가 아니라 **글자를 감싸는 크기**로 그리도록 바꿨다.
   창 전체를 칠하던 동안에는 여백을 키워도 글자만 안으로 밀릴 뿐 배경
   크기는 그대로여서, 설정 항목을 만들어도 의미가 없었다.

2) 평소 잠금 + '자막 위치 조정'
   locked 기본값을 True 로 바꾼다. 게임 중에 자막을 실수로 끌고 다니는
   사고를 막는 쪽이 기본이어야 한다. 자막 화면의 버튼으로 조정 모드를
   열면 그때만 이동·네 모서리 크기 조절·휠이 열린다. 조정 중에는 클릭
   통과를 강제로 끈다 — 켜둔 채로는 마우스가 창을 지나가 잡을 수 없다.
   크기를 바꾸면 글자 크기가 높이 비율을 그대로 따라간다. 상자만 커지고
   글자가 그대로면 여백만 늘어나 '자막을 키우는' 조작이 되지 않는다.
   조정 모드는 저장하지 않는다. 다음 실행은 다시 잠긴 상태로 시작한다.

3) GPU 가 없을 때 CPU 추천
   티어 표의 recommended 는 GPU 가 넉넉할 때를 전제한 값이라, 그대로
   쓰면 GPU 없는 사람에게 10GB 짜리를 권하게 된다. 환경을 보고 고르는
   recommended_tier() 를 만들고 배지도 그 결과로 붙인다. GPU 가 없으면
   CTranslate2 계열만 후보에 두고 가장 가벼운 쪽을 권한다 — LLM 계열은
   CPU 에서 한 문장에 수십 초가 걸려 실시간 자막이 되지 않는다.
   '추천대로 맞추기' 버튼과, 실제로는 CPU 로 도는데 화면은 GPU 라고
   표시하던 것도 같이 고친다. nvidia-smi 실패 문구가 명령줄을 통째로
   토해내던 것도 사람이 읽을 문장으로 바꿨다.

4) 마이크 등 입력 장치 선택
   InputDeviceCapture 를 추가하고 홈 화면에 목록 전환 버튼을 둔다.
   두 목록을 한 번에 섞지 않는다 — 다 합치면 정작 찾으려던 게임이
   라인입력들 사이에 파묻힌다. 버튼 글자는 '지금 상태'가 아니라
   '누르면 가는 곳'을 적는다. 고른 쪽은 설정에 기억한다.
2026-09-29 13:27:13 +09:00
EJClaw
bec5e285bd fix: exe 점검이 본 화면 대신 자막 오버레이를 보고 통과하던 것
Some checks failed
Windows / verify (push) Has been cancelled
"아무 창이나 뜨면 통과" 로 둔 탓에, 자막 오버레이 창(제목이 앱 이름뿐이다)이
잡혀서 본 화면이 안 떠도 통과할 수 있었다. 지난 두 번의 오보가 전부 같은
형태였다 — 느슨한 통과 조건. 어떤 창을 기다리는지 호출하는 쪽이 지정하게
바꾼다. 첫 실행은 저장 위치 화면, 두 번째 실행은 본 화면 제목에만 있는
표어를 기다린다. 찾은 창 목록을 전부 기록해 나중에 되짚을 수 있게 한다.
2026-09-28 18:17:46 +09:00
EJClaw
7f678fba7b fix: exe 점검이 자식 프로세스를 안 죽여 임시폴더 정리에서 실패하던 것
Some checks failed
Windows / verify (push) Has been cancelled
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고 창과 로그 파일을 쥐고 있는
것은 자식이다. 부모만 terminate 하니 자식이 livesub.log 를 계속 잡고 있어
임시 APPDATA 를 지울 때 PermissionError 가 났다. exe 의 문제가 아니라
점검이 뒷정리를 못 한 것인데 exe 기동 실패로 보고됐다.

- 프로세스 트리를 통째로 정리한다
- 임시폴더 삭제는 실패해도 점검을 실패시키지 않는다
- 두 번째 실행에서 저장 위치 화면이 또 뜨면 실패로 본다. "기억한다"는
  약속이 깨진 것이므로 본 화면이 아닌 창은 통과가 아니다
2026-09-28 18:12:47 +09:00
EJClaw
628280b227 fix: exe 가 켜지자마자 ImportError 로 죽던 것
Some checks failed
Windows / verify (push) Has been cancelled
포터블 exe 를 실행하면 창 대신 오류창만 떴다.

  File "__main__.py", line 1, in <module>
  ImportError: attempted relative import with no known parent package

PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다
(__package__ 가 비어 있다). 그래서 src/livesub/__main__.py 첫 줄의
`from .app import main` 이 그 자리에서 죽는다. 절대 임포트로 바꾼다.
`python -m livesub` 는 둘 다 동작하므로 잃는 것이 없다.

왜 못 잡았나 — exe 점검이 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다.
PyInstaller 오류창은 프로세스를 그대로 띄워둔 채 서 있으므로 통과로
보고됐다. 살아 있는 것은 떴다는 뜻이 아니다. 고친다:

- 프로세스 생존이 아니라 **실제 창**을 본다. onefile 은 자식 프로세스가
  창을 띄우므로 프로세스 트리 전체를 훑는다.
- "Unhandled exception" / "Failed to execute script" 오류창을 보면
  그 자리에서 실패로 판정한다.
- APPDATA 를 빈 폴더로 돌려 첫 실행(저장 위치 화면)과 두 번째 실행
  (본 화면)을 각각 확인한다. 둘 다 실제 창 제목으로 확인한다.
- 같은 회귀를 리눅스에서도 잡도록 runpy 로 패키지 없는 실행을 재현하는
  테스트를 추가한다. 되돌려보면 실제로 실패하는 것을 확인했다.
2026-09-28 18:06:09 +09:00
EJClaw
88effe0e58 docs: 저장 위치 선택 안내와 실제 Windows 화면
Some checks failed
Windows / verify (push) Has been cancelled
그림은 win-ci VM 의 Windows 11 에서 windows_smoke 가 직접 찍은 것이다.
경로 표기(C:\Users\...)와 디스크 여유 공간이 실제 값이어야 의미가 있다.
2026-09-26 01:01:13 +09:00
EJClaw
81e0271b69 feat: 저장 위치를 첫 실행 때 고르고 기억한다
Some checks failed
Windows / verify (push) Has been cancelled
모델이 티어에 따라 2~17GB 를 차지하는데 %APPDATA% 에 박혀 있어서 C 드라이브가
좁은 사람은 손쓸 방법이 없었다. 처음 켤 때 한 번 묻고, 그 선택을 기억하고,
설정에서 바꿀 수 있게 한다.

닭과 달걀 문제가 하나 있다 — "어디에 둘지" 는 사용자가 정하는데 그 선택 자체는
고정된 곳에 적어둬야 다음 실행에 찾을 수 있다. 그래서 OS 표준 폴더에는 위치를
가리키는 쪽지(location.json) 한 장만 두고, 내용물은 전부 고른 폴더에 둔다.

- storage.py 신설: 쪽지 읽기/쓰기, 경로 검증, 여유 공간, 데이터 이동
- config.py 의 CONFIG_PATH 를 config_path() 로 바꾼다. import 시점 상수라
  위치를 바꿔도 옛 경로를 계속 가리켰다
- 위치가 정해지기 전에는 파일 로그를 열지 않는다. 열면 고르지도 않은 기본
  폴더가 만들어지고 거기에 로그가 남는다
- 이전 버전 사용자에게는 묻지 않는다. 기본 위치에 쓰던 흔적이 있으면 그대로
  쓴다 — 같은 자리를 다시 고르는 헛수고를 시킬 이유가 없다
- 고른 디스크가 빠져도 죽지 않고 기본 위치로 물러난다
- 위치를 바꿀 때 받아둔 모델을 옮길지 물어본다. 수 GB 라 창이 얼어붙지
  않도록 별도 스레드에서 옮긴다
- windows_smoke 에 이 화면 렌더링 점검을 추가하고, 스크린샷을 호스트로
  회수한다. 경로 표기와 디스크 조회는 OS 마다 다르게 도는 부분이다
2026-09-26 00:57:04 +09:00
EJClaw
f79293d893 docs: win-ci 운영 기록 — MSVC 가 필요한 이유와 거짓 PASS 함정
Some checks failed
Windows / verify (push) Has been cancelled
- 검증 통과한 포터블 exe 가 results/LiveSub.exe 로 회수된다는 것
- 산출물의 존재를 성공 근거로 쓰면 안 되는 이유 (옛 exe 가 남아 PASS)
- MSVC 없이는 캡처 도우미가 조용히 빠지고, 그래서 검증 순서가 중요하다는 것
2026-09-26 00:43:13 +09:00
EJClaw
2028b0a651 fix: 프로그램별 캡처 도우미가 애초에 컴파일되지 않던 것
Some checks failed
Windows / verify (push) Has been cancelled
native\process_loopback\main.cpp 이 MSVC 에서 C2275 로 죽는다. WRL 의
FtmBase 는 플래그가 아니라 섞어 넣는 클래스인데 RuntimeClassFlags 안에
`ClassicCom | FtmBase` 로 넣어 타입을 값 자리에 썼다. 인터페이스 목록
쪽으로 옮긴다 (ApplicationLoopback 샘플과 같은 형태).

이 코드는 지금까지 한 번도 빌드된 적이 없다. 리눅스에서는 컴파일할
방법이 없었고, CI 의 네이티브 빌드 단계는 continue-on-error 였다.
win-ci VM 에 MSVC 를 깔고 나서야 드러났다.
2026-09-26 00:38:56 +09:00
EJClaw
4fd8fabcb8 build: 포터블 exe 에서 빠져 있던 프로그램별 캡처 도우미를 복구
Some checks failed
Windows / verify (push) Has been cancelled
livesub_capture.exe 가 없으면 포터블 빌드는 그대로 성공하되 프로그램별
캡처가 장치 전체 캡처로 조용히 폴백된다. 핵심 기능이 빠진 exe 가 나오는데
빌드는 PASS 로 보이는 조합이라 그대로 릴리스될 뻔했다.

- win-ci VM 에 VS 2022 Build Tools(C++)를 깐다. WASAPI 프로세스 루프백
  헤더와 WRL 은 Windows SDK + MSVC 에만 있어서 cmake 만으로는 못 만든다.
- 검증 순서에 native_capture 단계를 windows_smoke 앞에 넣는다. 뒤에 두면
  smoke 와 spec 이 둘 다 "없음"으로 통과해 버린다.
- pyinstaller 단계가 거짓 PASS 를 내던 것을 고친다. 앞 회차의 exe 가
  실행 중이면 PermissionError 로 빌드가 죽는데, 남아 있는 옛 exe 때문에
  Test-Path 검사는 통과했다. 빌드 전에 프로세스를 죽이고 build/dist 를
  지우며 PyInstaller 종료코드도 확인한다.
- 통과한 exe 를 호스트로 회수한다. serve-host.py 는 32MB 상한이라 exe 를
  413 으로 튕겼다. 상한을 올리고 조각으로 받아 디스크에 바로 쓰며,
  .part 로 받아 완료 후 옮겨 반쪽 파일이 결과물로 남지 않게 한다.
- .gitignore 의 네이티브 산출물 경로가 예전 이름(hearo)이라 빌드한
  exe 가 무시되지 않던 것을 livesub 로 고친다.
2026-09-26 00:24:31 +09:00
EJClaw
ca096a69c3 ci: 회수한 증거 파일의 한글이 깨지던 것 수정 + 문서화
Some checks failed
Windows / verify (push) Has been cancelled
[Console]::OutputEncoding 을 UTF-8 로 두지 않으면 PowerShell 이 자식
프로세스 stdout 을 콘솔 코드페이지(LTSC en-US = 437)로 읽는다. python 쪽은
PYTHONUTF8=1 로 UTF-8 을 내보내니 한글이 전부 깨지고, 회수한 증거 파일의
실패 메시지를 읽을 수 없었다(실제로 첫 수집본의 한글 트레이스백이 그랬다).

docs/WINDOWS-TESTING.md 에 증거 채널(POST /artifact -> results/)과
sync.sh <sha> 재검증 절차를 적었다.
2026-09-25 23:44:00 +09:00
EJClaw
0a1389a1d9 ci: 검증 대상 리비전을 명시하고 그것이 재실행 트리거가 되게 한다
Some checks failed
Windows / verify (push) Has been cancelled
게스트 agent 는 stage2.ps1 의 해시가 바뀔 때만 다시 돈다. 그래서 커밋을
푸시해도 재검증이 안 걸렸고, 지금까지는 주석에 "rev2" 같은 문구를 넣어
해시를 억지로 바꿨다. 무엇을 검증한 결과인지도 모호했다.

stage2.ps1 에 $TargetRev 를 두고 `./sync.sh <sha>` 가 그 값을 박아서
배포한다. 검증 요청(= 리비전 지정)이 그대로 재실행 트리거가 되고,
움직이는 origin/main 대신 지정한 커밋을 reset --hard 로 고정해 본다.
2026-09-25 23:39:45 +09:00
EJClaw
a66eedee8b ci: win-ci 검증 출력을 호스트 파일로 회수한다 (증거 기준선)
Some checks failed
Windows / verify (push) Has been cancelled
VM 을 만든 이유가 "리눅스에서 통과한 게 Windows 에서 깨지는 것을 재현
가능하게 본다" 인데, 정작 실패 트레이스백이 호스트에 한 줄도 안 남았다.
로그 채널은 마지막 몇 줄만 흘렸고(Select-Object -Last 30), serial.log 는
VM 이 뜰 때마다 truncate 되고, guest.log 는 stage2 가 다시 돌 때만 채워진다.
그래서 실패 내용을 화면에서 읽어 옮겨 적는 상태였고 — 고친 뒤에 고쳐졌다는
것을 증명할 기준선이 없었다.

- serve-host.py: POST /artifact/<name> 추가. results/<name> 에 파일로 저장
  (경로는 basename 으로만 취하고 32MB 상한)
- stage2.ps1: Step 이 각 단계의 출력 전문을 StreamWriter(AutoFlush)로 받아
  호스트로 업로드한다. 중간에 예외로 죽어도 거기까지는 남는다.
  출력 자르기를 없애고 pytest 는 --tb=long, 검증 리비전을 박은
  summary.log 도 함께 올린다
- sync.sh: 스크립트만 /home/claude/win-ci 로 반영한다. build.sh 를 다시
  돌리면 지워둔 Windows ISO 4.8GB 를 다시 내려받는다

첫 수집 결과(rev 162540b)로 실제 원인 두 개를 특정했다.
2026-09-25 23:38:48 +09:00
EJClaw
dbe1370425 style: models 패키지 ruff 위반 정리 (import 정렬, 불필요한 따옴표 타입)
.gitignore 가 src/livesub/models 를 통째로 무시하던 동안 이 8개 파일
1,674줄은 커밋에서만 빠진 게 아니라 ruff 검사 범위에서도 빠져 있었다
(ruff 는 기본적으로 .gitignore 를 존중한다). 추적 대상이 되면서 처음
검사에 들어와 나온 2건이다.

같은 파일들에 대해 확인한 것: 프로젝트 룰셋 통과, 전체 룰(--select ALL)
감사에서 정확성 계열(F/E9/PLE) 위반 없음 — 남는 건 프로젝트가 켜지 않은
문서화/스타일 룰뿐, 7개 모듈 import 스모크 전부 통과, tests 10개 파일이
이미 models 를 임포트해 실행 범위에는 들어 있었다.
2026-09-25 23:38:34 +09:00
EJClaw
03a2ca50c0 fix: PyInstaller 가 webrtcvad 훅에서 죽어 포터블 exe 가 안 나오던 문제
Windows 포터블 빌드가 dist\LiveSub.exe 를 만들지 못했다. 원인은
pyinstaller-hooks-contrib 의 hook-webrtcvad.py 가 copy_metadata('webrtcvad')
를 부르는 것이다. 우리가 설치하는 배포 이름은 webrtcvad-wheels 라
(원본 webrtcvad 는 Windows 휠이 없어 MSVC 컴파일이 필요하다)
PackageNotFoundError 가 나고, PyInstaller 는 훅 import 실패를
ImportErrorWhenRunningHook 으로 올려 빌드를 통째로 중단한다.

packaging/pyinstaller-hooks/hook-webrtcvad.py 로 같은 이름의 훅을 덮어써
실제 배포 이름으로 메타데이터를 수집한다. spec 의 hookspath 에 등록했으므로
build-portable.ps1, .gitea 워크플로, VM 자체 검증이 모두 같은 경로를 탄다.

리눅스에서 최소 재현으로 확인: 훅 없이는 같은 ImportErrorWhenRunningHook,
훅을 넣으면 "Processing standard module hook ... from packaging/pyinstaller-hooks"
로 우리 훅이 선택되고 빌드 성공, 만들어진 바이너리에서 webrtcvad.Vad(2) 생성까지 통과.
2026-09-25 23:38:34 +09:00
EJClaw
e234ad4401 fix: webrtcvad 가 깔린 환경에서만 분할기 테스트 5개가 깨지던 원인 제거
Windows CI 에서 test_segmenter.py 5개가 실패하고 로컬에서는 통과했다.
플랫폼 차이가 아니라 선택 의존성 차이였다.

테스트 입력은 220Hz 순음이다. `_is_speech` 는 에너지 판정과 webrtcvad 를
AND 로 묶는데, webrtcvad 는 사람 목소리로 학습돼 있어 순음을 발화로 보지
않는다. 포터블 exe 의 requirements 에 webrtcvad-wheels 가 들어 있으니
Windows CI 는 게이트가 켜진 환경이고, 개발 venv 에는 없어서 안 드러났다.
(개발 venv 에 webrtcvad-wheels 를 넣으면 리눅스에서도 5개가 똑같이,
같은 수치로 깨지는 것을 확인했다.)

- 이 파일이 검증하려는 건 분할 상태 기계이므로 autouse fixture 로
  선택 의존성을 끊어 환경과 무관하게 같은 경로를 시험한다
- 게이트 동작 자체는 test_webrtcvad_gate_filters_pure_tone 이 따로 본다
  (webrtcvad 없으면 skip)
- dev extras 에 webrtcvad-wheels 를 넣어 같은 환경 드리프트가 재발하지
  않게 한다
2026-09-25 23:38:17 +09:00
EJClaw
162540b025 docs: win-ci 운영 기준 명시 — 영구 유닛 확인법과 디스크 회수 기준
Some checks failed
Windows / verify (push) Has been cancelled
리뷰에서 나온 두 가지 혼선을 문서로 못 박는다.

1) systemd-run 트랜지언트로 띄우면 /run 에만 남아 재부팅에 사라진다.
   is-enabled 가 'transient' 로 보이면 영구 유닛이 아니라는 뜻이므로,
   FragmentPath 가 ~/.config/... 를 가리키는지 확인하는 절차를 넣었다.
2) 설치 후 무엇을 지우는지 기준을 표로 박았다. 판단 기준은 'VM 이 도는 데
   계속 필요한가' 하나다. Windows ISO(4.8GB)만 지우고, VM 에 물려 있는
   virtio-win.iso 와 unattend.iso 는 남긴다.

같은 ISO 를 버전별로 여러 개 남기지 말라는 주의도 넣었다. 어느 것이 물려
있는지 헷갈리면 고친 줄 알았던 스크립트가 반영되지 않는다.
2026-09-25 23:18:34 +09:00
EJClaw
f37da0cbe2 ci: 설치 미디어 없이도 win-ci VM 이 뜨도록
Some checks failed
Windows / verify (push) Has been cancelled
설치가 끝나면 Windows ISO(4.8GB)는 쓸모가 없어 지운다. 그런데 없는 파일을
-drive 로 넘기면 qemu 가 기동 자체에 실패해서, 재부팅 후 러너 호스트가
조용히 사라진다. cdrom 은 있을 때만 붙인다.
2026-09-25 23:04:28 +09:00
EJClaw
89b0ee0cf7 ci: win-ci VM 을 재부팅 후에도 살아나게 + VM 안 자체 검증 절차
Some checks failed
Windows / verify (push) Has been cancelled
트랜지언트 유닛은 재부팅에 안 남는다. 러너 호스트가 재부팅마다 사라지면
CI 가 조용히 대기 상태가 되므로 영구 user 유닛으로 박는다 (linger=yes 라
로그인 없이도 올라온다). cgroup/CPU 상한은 트랜지언트 때와 동일하다.

stage2 에는 러너 등록 토큰 없이도 워크플로와 같은 단계를 VM 안에서 직접
돌리는 검증을 넣었다. 첫 실행에서 실제로 결함 하나를 잡았다.
2026-09-25 23:03:40 +09:00
EJClaw
f9fd86464d fix: .gitignore 가 소스 패키지 src/livesub/models 를 통째로 먹고 있었다
Some checks failed
Windows / verify (push) Has been cancelled
`models/` 처럼 슬래시 없이 쓴 규칙은 깊이에 상관없이 같은 이름의 폴더를 전부
무시한다. 의도는 루트의 모델 캐시였는데 소스 패키지까지 같이 걸렸고, 그래서
asr/translator/manager/tiers/glossary/packs/speech_level 8개 파일이 한 번도
커밋된 적이 없다.

증상: Gitea 에서 clone 하면 `ModuleNotFoundError: No module named
'livesub.models'` 로 앱이 아예 임포트되지 않는다. 로컬 작업 트리에는 파일이
있으니 개발 중에는 절대 안 보인다. 방금 붙인 Windows CI VM 이 첫 검증에서
바로 잡아냈다.

/models/, /adapters/, /data/ 로 루트에 고정하고 빠진 파일을 추가한다.
2026-09-25 23:01:31 +09:00
EJClaw
ae295dc308 fix: 무인 프로비저닝이 통째로 안 돌던 인코딩/파이프라인 결함 3건
실제 설치에서 드러난 것들이다. 셋 다 증상이 원인과 전혀 안 닮아서 기록해 둔다.

1) provision.ps1 이 1행부터 파싱 실패 - 'Missing closing }'
   Windows PowerShell 5.1 은 BOM 없는 .ps1 을 ANSI(CP1252)로 읽는다.
   한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(0x93/0x94 = " ")로
   변하고, 파서가 그걸 문자열 시작으로 보고 뒤를 전부 삼킨다.
   -> build.sh 가 ISO 에 넣을 때 UTF-8 BOM 을 붙인다.

2) stage2.ps1 의 1행이 주석으로 인식되지 않음
   agent 가 받은 본문에 BOM 이 있으면 Set-Content -Encoding UTF8 이 BOM 을
   한 번 더 붙여 BOM 이 두 개가 된다. PowerShell 은 첫 개만 벗겨내고 남은
   U+FEFF 때문에 '#' 이 줄 맨 앞이 아니게 된다.
   -> agent 가 TrimStart(U+FEFF), 서버는 charset=utf-8 을 명시한다.

3) Git 설치가 'Cannot convert System.Object[] to System.String' 로 실패
   Log 가 Write-Output 이라 함수 안에서 부르면 그 함수의 반환값에 로그가
   섞인다. Get-File 이 배열을 돌려주니 Start-Process 가 죽었다.
   -> Log 를 Write-Host 로 바꿨다.

덤으로 시리얼/HTTP 로그의 한글이 '?' 로 깨지던 것도 고쳤다 (SerialPort 기본
인코딩이 ASCII). 그리고 러너 토큰이 없어도 워크플로와 같은 단계를 VM 안에서
직접 돌리는 검증 절차를 stage2 에 붙였다.
2026-09-25 22:50:08 +09:00
EJClaw
e41d0cb441 ci: .9 안 Windows CI VM 무인 구축 자동화
Windows 설치 화면을 사람이 클릭할 수 없으므로 전 과정을 무인으로 짰다.
autounattend.xml 이 설치와 OOBE 를 끝내고, provision.ps1 이 virtio 드라이버를
오프라인으로 넣어 네트워크를 살리고, 상주 agent.ps1 이 호스트 HTTP 에서
stage2.ps1 을 받아 Git/pwsh7/Python/Node/CMake/act_runner 를 설치한다.

설치 내용을 ISO 에 박지 않고 호스트가 HTTP 로 내려주는 구조로 한 이유는
ISO 를 한 번 구우면 못 고쳐서, 한 줄 고칠 때마다 Windows 재설치가 되기
때문이다. stage2.ps1 만 고치면 게스트가 20초 안에 반영한다.

- 시스템 디스크는 SATA. virtio-blk 로 하면 Setup 이 디스크를 못 봐서
  WinPE 드라이버 주입이 필요해진다
- VM 은 systemd --user 트랜지언트 유닛으로 띄워 봇 cgroup 밖에 둔다
  (MemoryHigh=16G 안에 8G VM 이 들어가면 봇이 OOM 으로 죽는 전례)
- Node 포함. act_runner host 모드가 JS 액션을 PATH 의 node 로 돌린다
2026-09-25 22:13:55 +09:00
EJClaw
d0028abdac docs: 100% 부하 실측 — .9 CPU 증설은 오히려 해롭다 (이전 권고 정정)
Some checks failed
Windows / verify (push) Has been cancelled
"VM 이 실제 윈도우와 다른가, 100% 부하에서도 자원이 괜찮은가" 에 대한 답.
앞선 측정은 한가할 때 값이라 부하를 걸어 다시 쟀고, 결론이 하나 바뀌었다.

이전에 "여유용으로 vCPU 12까지 가능" 이라고 적었는데 틀렸다. 늘리면 .5 의
운영 컨테이너 19개가 느려진다. 물리 호스트가 i7-11700 16스레드뿐이라
.9 의 지분이 커지는 만큼 .5 가 밀린다. .5 응답 속도 실측:

  .9 한가할 때        616~628ms   기준
  .9 가 4코어 사용    657~678ms   +7%    <- VM 100% 인 실제 상황, 무시 가능
  .9 가 8코어 전부   1487~1491ms  +140%  <- 봇까지 동시에 바쁠 때

VM 4 vCPU 는 안전하다. .9 내부 영향도 작다 (파이프라인 9.8 -> 11.0초).
최악(8코어 전부)을 막으려면 CPUQuota=400% + CPUWeight=50 으로 띄운다.
CPUQuota=200% 로 재봤을 때 .5 응답이 649~653ms 로 기준선과 같았다.

RAM 은 최악을 다 더해도 21.6GB / 30GB 로 8.4GB 여유가 있다.
QEMU 오버헤드는 -m 8192 기준 VmSize 8.22GB 로 약 0.2GB.

VM 의 Windows 가 실제와 다른지도 정리했다. 같은 바이너리이고 WASAPI,
RegisterHotKey, 모니터 열거, PyInstaller 모두 코드 경로가 동일하다.
다른 건 GPU 없음, 실제 게임 소리 없음, 속도/지연 세 가지뿐이다.

부수 수확: virtio-vga,max_outputs=2 가 KVM 으로 기동되는 것을 확인했다.
--vga std 는 화면이 1개라 모니터 선택 기능을 검증할 수 없는데, 이걸 쓰면
화면 2개를 줄 수 있어 사용자가 요청한 모니터 선택 기능도 자동 검증 가능하다
(게스트에 virtio-gpu 드라이버 필요).

결론: CPU·RAM 모두 증설 없이 진행. RAM 만 원하면 38GB 까지 가능. CPU 는 금지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 04:05:31 +09:00
EJClaw
fb88cea427 docs: .9 안 Windows VM 크기와 호스트 증설 필요량을 실측으로 확정
Some checks failed
Windows / verify (push) Has been cancelled
"VM 만들기 전에 CPU/RAM 을 얼마나 늘려줄까" 에 대한 답. 감이 아니라 재봤다.

코어는 4개에서 포화한다. 더 줘도 빨라지지 않는다.
  파이프라인(음성->자막)  2코어 13초 / 4코어 10초 / 8코어 11초
  테스트 190개            4코어 5.9초 / 8코어 5.8초
CI 작업 대부분이 단일 스레드고, 병렬인 음성인식도 4개에서 멈춘다.

물리 호스트는 i7-11700(8코어/16스레드) / RAM 62.6GB.
호스트 스펙은 Proxmox 에 SSH 가 안 되어 .5 LXC 를 통해 확인했다.
/proc/meminfo 는 lxcfs 가 컨테이너 한도로 가려서 16GB 로 보이지만
/sys/devices/system/node/node0/meminfo 는 가려지지 않아 실제 값이 나온다.

.9 는 그중 8 vCPU / 30GB 를 쓰고 있고, 실측상 증설이 거의 불필요하다.
  ejclaw 최대 7.2GB (상한 16GB 는 여유분) + VM 8GB = 15.2GB / 30GB
  코어 8개면 VM 4 + 봇 4 로 충분

결론: VM 은 vCPU 4 / RAM 8GB / 디스크 80GB. 호스트는 그대로 둬도 되고,
여유를 두려면 vCPU 12 / RAM 38GB 가 상한이다 (호스트 16스레드 중 .5 가
4개를 쓰고, 호스트 여유 RAM 이 페이지 캐시 포함 약 24GB 이므로).
.5 에서 운영 컨테이너 19개가 도는 점도 같이 적어뒀다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:59:53 +09:00
EJClaw
8a86e7e7ac fix: 티어 게이트가 실제 제약(autoawq·VRAM)을 보도록 수정
Some checks failed
Windows / verify (push) Has been cancelled
실제 GPU 로 검증을 돌리다 발견한 버그다. tier_availability() 가 torch 유무만
봐서, RTX 5050(7.5GB)에서도 정밀·극한 티어를 "사용 가능"이라고 답했다.

  정밀(AWQ Int4)  autoawq 가 있어야 로드된다. requirements.txt 에 없는
                  선택 의존성이라 대개 없다.
  극한(Qwen3-8B)  min_vram_gb=16. 7.5GB 카드에서는 영영 불가능하다.

둘 다 "고를 수 있다"고 해놓고 로드에서 죽는다. 사용자는 원인을 알 수 없다.
UI 에 min_vram_gb 경고 배지는 있었지만 경고만 하고 선택은 막지 않았다.

- tier_availability(tier, gpu=None) 에 autoawq·VRAM 검사 추가.
  새 필드를 만들지 않고 이미 있던 min_vram_gb 를 쓴다.
  free 가 아니라 total 로 본다 — free 로 막으면 다른 프로그램 때문에
  "아까는 되던 티어가 지금은 안 보인다"가 된다.
  CT2 티어(1~3)는 조기 반환이라 detect_gpu 를 아예 부르지 않는다.
  (포터블에서 torch 도 GPU 도 없이 돌아야 하므로)

- GpuInfo 를 인자로 받아 루프에서 nvidia-smi 반복 호출을 막았다.
  models_page 는 티어 카드 5개를 만들며 매번 조회하던 것을 __init__ 에서
  한 번만 하도록 바꿨다. 빌더 함수의 부수효과에 의존하던 것도 제거.

- 점검기의 판정 기준도 고쳤다. 기존엔 "4·5티어가 전부 열려야 통과"였는데
  VRAM 작은 GPU 에서 막히는 건 정상이라 오탐이었다. 이제 게이트는 판정
  없이 보고만 하고, "게이트가 된다고 한 티어가 실제로 올라가는지" 가
  판정한다. 게이트가 전부 막았으면 검증 대상이 없다는 사실을 남기고
  통과시킨다 — 하드웨어 한계이지 회귀가 아니다.

- scripts/gpu-check.sh 추가. CTranslate2 가 libcublas.so.12 를 직접 찾는데
  torch 의 nvidia 패키지 안에 있어 LD_LIBRARY_PATH 가 필요하다. 없으면
  GPU 번역만 조용히 실패한다. 스크립트가 경로를 자동으로 잡는다.

검증: pytest 190 passed (신규 4개 중 3개는 torch 필요 -> gpu-venv 에서
      실제로 실행해 9 passed 확인), ruff clean,
      RTX 5050 에서 scripts/gpu-check.sh 6/6 통과 (종료코드 0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:55:22 +09:00
EJClaw
48b7106ac6 docs: .9 안 중첩 Windows VM 가능 여부를 실측으로 정리
Some checks failed
Windows / verify (push) Has been cancelled
"Proxmox 말고 .9 안에서 Windows VM 을 돌릴 수 없나" 에 대한 답.

가능하다. 주장이 아니라 실제로 띄워서 확인했다.
  /dev/kvm 존재 / kvm_intel nested=Y
  qemu -enable-kvm -> "kvm support: enabled"
  여유 RAM 25GB, 디스크 294GB (8G VM 수용 가능)

가장 큰 이점은 성능이 아니라 **사람 손이 안 간다는 것**이다. .9 안이라
에이전트가 VM 생성부터 러너 등록까지 직접 할 수 있고 Proxmox 자격증명이
필요 없다. 지금까지 막혀 있던 지점이 정확히 그거였다.

다만 GPU 문제는 전혀 해결되지 않는다.
  IOMMU 그룹 0개, 커널 cmdline 에 iommu 옵션 없음 -> 중첩 패스스루 불가
되게 하려면 Proxmox 가 vIOMMU 를 열고 .9 커널에 intel_iommu=on 을 넣고
GPU 를 vfio-pci 에 묶어야 하는데, 그러면 .9 가 GPU 를 잃는다. 결국
"GPU 를 누가 가질 것인가" 로 되돌아온다.

운영상 함정 하나를 실측으로 확인해 문서에 박아뒀다. 에이전트 턴에서 그냥
qemu 를 띄우면 VM 이 ejclaw.service cgroup(MemoryHigh=16G, 평시 5G 사용)
안에서 돌아 8G VM 이 상한을 치고 봇을 OOM 으로 죽인다(전례 있음).
systemd-run --user --scope 를 쓰면 app.slice 형제 scope 로 빠지는 것을
확인했다 — 독립 상한이 실제로 적용됨.

A(.9 중첩) vs B(Proxmox) 비교표 추가. CI 타임아웃 30분에 파이프라인이
27초라 중첩 오버헤드는 문제되지 않으므로 A 를 먼저 권한다.

부수 변경(이 저장소 밖): .9 에 qemu-system-x86/ovmf 설치, claude 계정을
kvm 그룹에 추가. /dev/kvm 이 root:kvm 0660 이라 권한이 없으면 qemu 가
"Could not access KVM kernel module: Permission denied" 로 죽는다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:49:55 +09:00
EJClaw
88b9d931eb ci: GPU 러너 동시 실행 차단 + 티어 검증을 실제 로드로 교체
Some checks failed
Windows / verify (push) Has been cancelled
러너를 여러 프로젝트가 공유한다는 전제에서 두 가지 구멍이 있었다.

1) 동시 실행 — GPU 는 나눠 쓸 수 없다. 두 작업이 같이 VRAM 을 잡으면
   둘 다 OOM 이거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다.
   concurrency(같은 저장소) + 러너 capacity:1(저장소 무관, 진짜 보장)을
   두 겹으로 두고, 문서에 capacity 확인을 필수 절차로 넣었다.
   한 대에 러너 프로세스를 여러 개 띄우면 안 된다는 것도 명시.

2) 티어 검증이 주장보다 얕았다 — "4·5티어 검증"이라 해놓고 실제로는
   tier_availability() 만 봤고, 그 함수는 torch 유무만 본다. 즉

     - 4티어(Seed-X AWQ Int4)는 autoawq 가 없어도 "사용 가능"
     - 5티어(Qwen3-8B)는 VRAM 11GB 가 필요한데 8GB 카드에서도 "사용 가능"

   게이트가 통과인데 실제로는 못 올리는 상태를 CI 가 못 잡고 있었다.
   그래서 게이트 검사는 얕은 검사임을 이름과 주석에 명시하고, 그 옆에
   **진짜로 모델을 올려 번역까지 하는** 항목을 새로 뒀다. 러너 GPU 가
   무엇일지 모르므로 여유 VRAM 과 autoawq 유무로 후보를 거른 뒤 올릴 수
   있는 것 중 가장 무거운 티어를 고르고, 건너뛴 티어와 그 이유를 결과에
   남긴다. 하나도 못 올리면 실패다 (조용히 통과하지 않는다).

   게이트는 통과인데 실제 로드가 실패하면 그게 찾아야 할 버그다 — 앱이
   사용자에게 "쓸 수 있다"고 하고선 못 올리는 상황. 러너가 붙으면 실제
   증거를 보고 tier_availability() 를 고칠지 판단한다.

- autoawq 설치 단계 추가(4티어 전용, Windows 휠이 없을 때가 있어 선택)
- 1~3티어 CUDA 경로 번역 검사는 유지하고 unload 를 finally 로 보장

검증: pytest 189개 통과, ruff clean, 워크플로 파싱·concurrency 확인,
      --gpu 를 torch 없는 환경에서 실행해 6개 중 5개가 의도대로 실패

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:47:15 +09:00
EJClaw
bb385c1d7b ci: GPU 검증을 별도 러너 라벨로 분리 (windows-gpu)
Some checks failed
Windows / verify (push) Has been cancelled
GPU 는 한 VM 만 독점한다 — 컨슈머 NVIDIA 는 vGPU/SR-IOV 를 지원하지 않아
쪼개 쓸 방법이 없고, 지금 RTX 5050 은 .9(리눅스)에 물려 있다. 따라서 GPU
없는 상시 CI VM 에서는 CUDA·VRAM·4~5티어를 영영 확인할 수 없다.

코드로 우회할 수 없는 제약이므로, 대신 **나중에 GPU 머신이 생겼을 때 그대로
붙도록** 구조를 지금 잡아뒀다. 이 러너를 다른 프로젝트 GPU 테스트에도 쓸
계획이라면 이 구분이 그쪽에도 그대로 적용된다.

  windows:host      GPU 없는 서버 VM   main push 마다 자동
                    테스트·Windows 경로·오디오→자막·포터블 exe
  windows-gpu:host  GPU 달린 머신      수동 실행만
                    CUDA 인식·VRAM 상한·4·5티어·GPU 번역

- windows_smoke.py --gpu
  GPU 있는 머신에서만 의미 있는 항목만 모았다. CUDA 가용성, 앱의 GPU 인식,
  VRAM 상한이 실제로 걸리는지, torch 필요 티어(4·5)가 열리는지, GPU 로 실제
  번역까지. 다른 모드와 달리 **GPU 부재를 치명적 실패로 처리**한다 —
  GPU 를 확인하겠다고 부른 작업이 조용히 통과하면 안 되기 때문이다.

- .gitea/workflows/windows-gpu.yml
  수동 실행 전용. 러너가 없으면 큐에 쌓이기만 하고 아무 일도 없다.
  포터블 CI 와 달리 torch CUDA 빌드를 설치하고, 설치 직후 CPU 빌드가
  깔리지 않았는지 확인한다. nvidia-smi 가 없으면 그 자리에서 멈춘다.

- 문서에 두 러너 구조와 GPU 머신 확보 3가지 방안(서버에 GPU 추가 /
  .9 에서 옮기기 / 필요할 때만 실물 PC) + 슬롯 확인 명령 추가.

검증: pytest 189개 통과, ruff clean, 두 워크플로 파싱·라벨·트리거 확인,
      --gpu 를 torch 없는 환경에서 실행해 의도대로 실패(종료코드 1)하는지 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:40:01 +09:00
EJClaw
8d91087f85 ci: GPU 없는 서버 Windows VM 지원 + 전 파이프라인 자동 검증
Some checks failed
Windows / verify (push) Has been cancelled
RTX 5050 은 PCIe 패스스루로 이 VM(.9)이 독점하므로 Proxmox 에 Windows VM 을
띄워도 GPU 를 못 준다. 그래서 "Windows 검증에 GPU 가 정말 필요한가"를 실제로
재봤다 — GPU 를 완전히 가린 채 점검기를 돌린 결과, 실패한 건 'GPU 인식' 하나뿐이고
그건 이미 경고 처리라 빌드를 막지 않는다. 나머지(단축키·모니터·오디오 열거·
exe 빌드·기동·오버레이 렌더링)는 전부 통과한다. 즉 GPU 없는 서버 VM 으로 충분하고,
CUDA/VRAM 관련은 GPU 가 물려 있는 .9 에서 이미 검증 중이다.

- tests/fixtures/en_callout_16k.wav (139KB)
  MeloTTS 로 만든 4.4초 영어 콜아웃. CI 가 실제 음성으로 검증할 수 있게 커밋.

- windows_smoke.py --pipeline
  오디오 파일 -> 음성인식 -> 번역 -> 한국어 자막까지 끝까지 확인한다.
  부품이 다 통과해도 이어 붙이면 안 되는 경우를 잡기 위한 것.
  CPU + 가장 가벼운 티어로 돌려 GPU 없는 VM 에서도 그대로 된다.
  실측: 27초, 'Enemy coming from the left, fall back now.'
        -> '적 왼쪽에서 오는, 지금 뒤로 물러서.'
  음성인식 결과에 핵심 단어가 있는지, 자막에 한글이 있는지까지 검사한다.

- 워크플로에 해당 단계 추가 (30분 타임아웃, 첫 회만 모델 다운로드)

- docs/WINDOWS-TESTING.md 에 서버 VM 구축 절차
  Proxmox qm create 예시와 함께, 빠뜨리면 조용히 실패하는 두 가지를 명시:
  (1) --audio0 없으면 오디오 장치가 안 잡혀 WASAPI 점검이 실패
  (2) 자동 로그온이 없으면 데스크톱 세션이 없어 GUI/스크린샷이 전부 실패
      (러너를 서비스로 돌리면 안 되는 실질적 이유이기도 하다)

검증: pytest 189개 통과, ruff clean, 워크플로 11스텝 파싱 확인(bash 0개),
      GPU 숨긴 조건에서 점검기 실행, --pipeline 실제 통과

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:32:35 +09:00
EJClaw
bef9eaf19b fix: Windows 러너 첫 단계 bash 의존 제거 + 필수 설치 목록 보완
Windows 호스트 러너에서 bash.exe 는 기본 PATH 에 없다 (Git for Windows 는
Git\cmd 만 PATH 에 등록하고 bash.exe 는 Git\bin 에 있다). OS 확인 단계가
첫 스텝이라 러너를 붙이자마자 "bash not found" 로 죽는다. pwsh 로 바꿨다.

같은 이유로 문서 설치 목록에 Git.Git 과 Microsoft.PowerShell 을 추가했다.
나머지 7개 스텝이 전부 pwsh(7.x) 를 쓰는데 Windows 기본 탑재는 5.1 뿐이라
pwsh 없이는 단계마다 실패한다.

트리거 설명도 실제 on: 설정(main push / workflow_dispatch)에 맞춰 고쳤다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:21:27 +09:00
EJClaw
4af97a68f2 fix: Windows 점검기 SIGABRT 수정 + 러너 안내 정확하게
Some checks failed
Windows / verify (push) Has been cancelled
리뷰 지적을 확인해보니 두 건은 실제 결함이었다.

1) 점검기가 화면 없는 환경에서 그냥 죽음 (SIGABRT, 종료코드 134)
   QT_QPA_PLATFORM 을 "windows" 로 하드코딩해 둔 탓에, 표시할 화면이 없으면
   첫 QApplication 생성에서 플러그인 로드에 실패하며 아무 결과도 못 남기고
   abort 했다. 점검기가 그렇게 죽으면 존재 이유가 없다.
   - 화면이 없으면 offscreen 으로 떨어지게 하고, 이 판단을 Qt import 전에 한다
   - QApplication 생성을 _qt_app() 한 곳으로 모음
   - 실행 플랫폼을 출력해 리눅스에서 돌아간 경우가 바로 보이게 함
   이제 환경변수 없이 돌려도 항목별 판정과 종료코드가 정상으로 나온다.

2) 보안 설명이 과장돼 있었음 — 가장 중요한 수정
   "커밋된 워크플로만 실행하니 임의 명령은 불가"라고 썼는데, 나는 이 저장소
   main 에 push 할 수 있으므로 워크플로를 고쳐 커밋하면 그 PC에서 원하는
   명령을 돌릴 수 있다. 개인 PC를 내주는 판단에 쓰일 설명을 틀리게 준 것이라
   그대로 바로잡았다. 실제로 보장되는 것(숨길 수 없음·언제든 끌 수 있음·
   계정 권한으로만 동작)만 적고, VM 또는 전용 계정을 권하도록 바꿨다.
   서비스 등록(SYSTEM 권한) 안내도 로그온 작업(계정 권한)으로 교체 —
   "관리자로 띄우지 말라"는 권고와 모순됐다.

3) 라벨 오타 안전장치 (공식 문서 확인 중 발견)
   Gitea 는 runs-on 이 어떤 라벨과도 안 맞으면 실패시키지 않고 기본 우분투
   컨테이너에서 돌린다. Windows 검증이 조용히 리눅스에서 돌아가 "통과"로
   보일 수 있어, 워크플로 첫 단계에서 RUNNER_OS 를 확인해 막는다.
   문서의 등록 라벨도 windows:host 로 명시 (생략 시 기본값이 host 이지만
   명시가 낫다 — docs.gitea.com/runner/labels).

4) 트리거 설명 정정: "아무거나 push" -> main push 또는 수동 실행.

검증: pytest 189개 통과, ruff clean, 워크플로 트리거/스텝 파싱 확인,
      QT_QPA_PLATFORM 없이 점검기 실행해 abort 없이 종료코드 1 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:17:11 +09:00
EJClaw
2fcd961f8f ci: Windows 자동 검증 추가 (Gitea Actions + 스모크 스크립트)
Some checks failed
Windows / verify (push) Has been cancelled
개발이 리눅스에서 이뤄지다 보니 Windows 전용 경로는 한 번도 실행해 본 적이
없다. 오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기 해당하고,
"리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있는 지점이다.

- .gitea/workflows/windows.yml
  push 때마다 Windows 러너에서 테스트 -> 스모크 -> 포터블 exe 빌드 ->
  빌드된 exe 기동 확인까지 하고 exe·스크린샷을 artifact 로 올린다.
  의존성을 packaging/requirements-portable.txt 로 깔아 torch 없는 포터블
  조건을 그대로 재현하고, torch 가 섞이면 바로 실패하게 했다.

- packaging/windows_smoke.py
  리눅스에서 확인 불가능한 항목만 모은 점검기. WASAPI 장치 열거, 소리 내는
  프로그램 열거, 전역 단축키 등록/해제, 실제 모니터 열거, GPU 인식,
  사용 가능 티어, 자막 오버레이 실제 렌더링(스크린샷 저장).
  한 항목이 실패해도 나머지를 계속 돌려 전체 그림을 한 번에 보여준다.
  러너 환경에 따라 없을 수 있는 항목(오디오 장치·보조 exe·GPU)은 경고로만
  두고, 코드 문제인 항목만 빌드를 실패시킨다.

- docs/WINDOWS-TESTING.md
  러너 등록 절차와, 러너 없이 직접 돌려보는 방법.

러너가 없으면 워크플로는 대기만 하므로 지금 상태에 영향이 없다.

검증: pytest 189개 통과, ruff clean, 워크플로 YAML 파싱 확인,
      스모크 스크립트를 리눅스에서 실행해 항목별 판정과 종료코드 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:11:31 +09:00
EJClaw
bede94ee48 fix: 포터블에 transformers 포함 + 실제 모델로 드러난 용어집 결함 수정
리뷰 지적(포터블에서 transformers 제외 -> NLLB 로드 실패)을 고치려고 실제
NLLB 를 받아 돌려봤고, 그 과정에서 용어집이 사실상 동작하지 않고 있었다는
것을 발견했다. 단위 테스트는 "모델이 자리표시자를 통과시킨다"는 틀린 전제
위에 서 있었다.

1) 포터블 패키징 (리뷰 지적)
   transformers 를 제외 목록에서 빼고 hiddenimports 에 넣었다. NLLB
   토크나이저가 AutoTokenizer 를 쓰기 때문이다. transformers 는 torch 가
   없으면 토크나이저 전용 모드로 뜨며 그게 우리 용도와 정확히 맞는다.
   torch 없는 환경에서 ctranslate2/transformers/faster-whisper import 와
   앱 기동을 검증하는 test_portable.py 를 추가했다.

2) 자리표시자 형식 (실측으로 발견)
   `⟦0⟧` 는 NLLB 가 괄호를 날려 생존률 0/3 이었다. 용어가 자막에서 그냥
   사라지고 있었다 ("Third party incoming" -> "0 들어오는"). 후보 8종을
   실제 모델로 비교해 `#0#` 로 교체 (3/3, 다중 4/5).

3) 소실 대비
   모델이 문장 일부를 누락하면 자리표시자도 사라진다. 그대로 복원하면
   용어가 증발하므로, 하나라도 없으면 보호 없이 재번역한다.

4) 서술어는 문장 전체일 때만 (whole_only)
   절/서술어를 문장 중간에서 치환하면 문법이 무너진다.
     before: "탄 필요해와 구급상자"
     after : "탄약과 구급상자가 필요합니다"
   해당 56개 항목을 whole_only 로 지정해 단독 발화일 때만 적용한다.
   ("Cover me!" -> "엄호해줘" 는 그대로 유지)

5) 조사 교정
   역어 받침이 달라 "자기장를" 이 남던 것을 fix_particles() 로 고친다.
   을/를, 이/가, 은/는, 과/와, (으)로 — 한글 코드에서 받침을 읽어 판정.

검증: pytest 189개 통과, ruff clean
      실제 NLLB-600M(torch 없이 CPU)로 번역 품질 직접 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 01:20:42 +09:00
EJClaw
71436843d8 feat: 존댓말/반말 모드, 포터블 exe 빌드, AI 로고/배너
말투 (존댓말 기본, 반말 선택)
- 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킨다. 상대가 정중하게
  말했는데 자막이 반말이면 뉘앙스가 뒤집히기 때문.
- 영어·중국어는 문법적 높임이 없으므로 항상 고른 모드를 따른다.
  "please" 를 존댓말 근거로 삼으면 오탐이 많아 쓰지 않았다.
- 낮춤 변환은 한글 자모를 분해해 실제 활용 규칙(모음조화 + 축약)을 구현했다.
  오+아->와, 지+어->져, 하+어->해, 았/었 뒤는 항상 어, 치겠습니다->칠게.
  어미를 나열하는 방식보다 훨씬 넓게 맞는다.
- 변환 방향은 존댓말->반말 한쪽만 한다. 번역 모델의 한국어 출력이 이미
  격식체라 존댓말 모드는 손댈 필요가 없고, 반대 방향은 훨씬 자주 틀린다.
- 지시문을 이해하는 Qwen3 에는 프롬프트로도 전달한다. Seed-X 는 지시문을
  못 알아듣는 모델이라 후처리로만 맞춘다.

포터블 exe
- PyInstaller 명세 + 빌드 스크립트. torch 를 의도적으로 제외했다.
  torch+CUDA 만 2.5GB 라 onefile 로 묶으면 실행할 때마다 그걸 임시폴더에
  푸느라 1분 넘게 걸려 쓸 수 없다.
- 음성인식(faster-whisper)도 번역(NLLB)도 CTranslate2 위에서 돌아 torch 가
  필요 없다. 덕분에 1~3티어는 그대로 다 되고 exe 는 3GB -> 500MB 가 된다.
- 4~5티어는 못 쓰므로 tier_availability() 로 판정해 모델 화면에 '사용 불가'와
  이유를 표시한다. torch 없는 환경에서 앱 전체가 뜨는 것을 확인했다.

AI 이미지
- SDXL-turbo 로 아이콘/배경 생성 (로컬 GPU, 피크 VRAM 1.9GB).
- 글자는 AI 가 제대로 못 쓰므로 아트만 AI 로 만들고 타이포그래피는
  정확히 렌더링해 합성했다. icon.ico 는 16~256px 멀티해상도.

검증: pytest 163개 통과 (말투 53개 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 01:06:23 +09:00
EJClaw
e4a048c8d5 fix: 필수 팩 기본 활성화 + 저부하 모드 토글 반영 + 용어 겹침 수정
리뷰 지적 2건과, 그것을 고치다 드러난 용어집 버그 1건을 함께 처리했다.

1) 저부하 모드 토글이 모델에 반영되지 않던 버그
   ModelManager 는 생성 시점의 low_power 로 정밀도를 정하는데 설정에서
   토글해도 갱신되지 않았고, stop() 은 모델을 내리지 않아 이전 정밀도
   모델이 그대로 남았다. TranslationEngine.sync_performance() 를 추가해
   값이 바뀌면 언로드 후 갱신하고, _bootstrap 과 재시작 경로에서 부른다.

2) 필수 게임 팩이 기본으로 꺼져 있던 문제
   새 설치 기본값을 fps-common + 오버워치 + 배그 + R6 + 워독스로 켰다.

3) (1)(2)를 고치며 드러난 것: 팩 여러 개를 켜면 결과가 체크 순서에
   휘둘렸다. knocked 가 FPS공통 "기절" / 배그 "눕혔다" 로 갈리는 식으로
   19개 용어가 충돌하는데, 사용자는 그 순서를 볼 수도 바꿀 수도 없었다.
   팩에 scope(generic/game) 를 두고 범용 -> 게임 전용 -> 사용자 항목
   순으로 적용해 결정적으로 만들었다. 게임 팩끼리 갈리는 것은 사람이
   판단할 문제라 find_conflicts() 로 뽑아 용어집 화면에 표시한다.

4) 용어 겹침 버그: "the blue"(조각)가 "blue zone" 보다 앞 위치라는
   이유로 먼저 잡혀 "자기장 자기장" 이 됐다. 최장일치 정렬은 같은 시작
   위치에서만 통한다. 조각 항목을 제거하고, 모든 팩을 켠 상태로 각
   용어가 정확히 한 번만 치환되는지 검사하는 테스트를 추가했다.

검증: pytest 110개 통과 (저부하 6 + 팩 계층/충돌/겹침 12 신규), ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 18:05:22 +09:00
EJClaw
a7eafd03e1 feat: 지정 게임 용어집 추가 (워독스·배그·R6·에이펙스)
사용자가 필수로 지정한 게임 중 오버워치를 뺀 세 개가 빠져 있었다.

- 레인보우 식스 시즈 67개 — 보강·하드브리치·앵커/로머·디퓨저·런아웃
- 배틀그라운드 66개 — 자기장·어부·낙하·파밍·3렙헬멧·스쿼드 콜
- 워치독스 59개 — 데드섹·ctOS·블룸·알비온 등 조직명과 해킹/잠입 용어
- 에이펙스 레전드 45개 — 링·배너·리스폰 비컨 (등등 항목으로 추가)

워치독스는 멀티 콜아웃이 아니라 스토리 대사가 주라, 번역기가 특히 잘 틀리는
고유명사(조직명)와 해킹 용어 위주로 골랐다.

전용 팩이 생긴 만큼 'FPS 공통' 설명을 장르 공통 콜로 좁혔다.
필수 게임이 빠지면 바로 잡히도록 test_required_games_are_bundled 추가.

번들 용어 304개 → 541개 (10개 팩)
검증: pytest 92개 통과, ruff clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 17:54:20 +09:00
EJClaw
8b36b244ff feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집
이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 17:49:26 +09:00
EJClaw
5596f905d1 fix: Seed-X 프롬프트 형식과 티어별 용어집 전략 수정
4티어(Seed-X-PPO-7B) 번역 경로가 모델 카드 요구사항을 어기고 있었다.

- 프롬프트 끝의 `<ko>` 등 대상 언어 태그가 빠져 있었다. PPO 학습에 쓰인
  신호라 없으면 번역 품질이 흔들린다.
- Seed-X 는 chat template 없는 번역 전용 completion 모델인데 "구어체로
  자연스럽게" 같은 지시문 래퍼를 씌우고 있었다. 학습 분포를 벗어난다.
- 그 결과 MTSpec.supports_prompt_glossary=True 가 사실과 달랐다.
  Seed-X 는 용어집 지시문을 이해하지 못하므로 플레이스홀더 치환을 써야 한다.

수정
- PromptStyle(NONE/SEEDX/INSTRUCT) 도입, supports_prompt_glossary 를
  prompt_style 에서 파생시켜 둘이 어긋날 수 없게 함
- build_seedx_prompt() 로 모델 카드 형식을 분리 (지시문 주입 불가)
- INSTRUCT 경로는 chat template 사용, Qwen3 thinking 모드는 끔
- LANGUAGES 에 seedx 태그 명시
- finetune_mt.py 가 티어의 prompt_style 을 따라가게 해 학습/추론 프롬프트 일치
- AWQ Int4 로드 실패 시 autoawq 설치 안내를 담은 오류 메시지

검증: pytest 52개 통과 (프롬프트 회귀 테스트 13개 추가), ruff clean

근거: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 10:56:49 +09:00
EJClaw
1a87ec6677 feat: Hearo 1차 구현 — 프로그램 소리 실시간 번역 자막
프로그램별 오디오를 캡처해 로컬 GPU에서 음성인식→번역하고 화면 위
자막으로 보여주는 데스크톱 앱. 한/영/일/중 4개 언어.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-21 10:47:11 +09:00
EJClaw
c24e5f938d chore: initialize paired workspace 2026-09-21 10:27:24 +09:00
116 changed files with 18075 additions and 1 deletions

View File

@@ -0,0 +1,88 @@
# Windows GPU 검증 — GPU 가 달린 러너에서만
#
# GPU 는 한 VM 만 독점한다(컨슈머 NVIDIA 는 vGPU 미지원). 그래서 GPU 없는
# 상시 CI VM(runs-on: windows)에서는 CUDA·VRAM·4~5티어를 확인할 수 없다.
# 그 항목만 떼어내 별도 라벨로 돌린다.
#
# 러너 라벨: windows-gpu:host
# - GPU 가 달린 Windows 머신에 등록한다 (두 번째 GPU를 꽂은 VM, 또는 실물 PC)
# - 상시로 띄울 필요 없다. 필요할 때만 daemon 을 켜면 된다
#
# 그래서 트리거도 **수동 실행만** 이다. 러너가 없으면 큐에 쌓이기만 하고
# 아무 일도 일어나지 않는다. 자동으로 돌리고 싶으면 push 트리거를 켜면 된다.
name: Windows GPU
on:
workflow_dispatch:
# GPU 는 나눠 쓸 수 없다. 두 작업이 동시에 VRAM 을 잡으면 둘 다 OOM 으로
# 죽거나, 더 나쁘게는 하나가 조용히 느려져 원인을 못 찾는다.
# 이건 같은 저장소 안에서만 막아준다. **다른 저장소까지 포함한 진짜 보장은
# 러너의 capacity: 1** 이다 (docs/WINDOWS-TESTING.md 참고).
concurrency:
group: windows-gpu
cancel-in-progress: false
jobs:
gpu:
runs-on: windows-gpu
timeout-minutes: 90
steps:
# 라벨이 안 맞으면 Gitea 가 기본 ubuntu 컨테이너로 떨어뜨린다.
# GPU 를 확인하겠다고 부른 작업이 엉뚱한 데서 도는 일은 막아야 한다.
- name: Windows 인지 확인
shell: pwsh
run: |
Write-Host "RUNNER_OS=$env:RUNNER_OS"
if ($env:RUNNER_OS -ne "Windows") {
Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)."
Write-Host "::error::러너를 'windows-gpu:host' 라벨로 등록했는지 확인하세요."
exit 1
}
- name: NVIDIA GPU 가 실제로 보이는지
shell: pwsh
run: |
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
if ($LASTEXITCODE -ne 0) {
Write-Host "::error::nvidia-smi 실패 — 이 러너에는 GPU 가 없습니다."
exit 1
}
- name: 소스 받기
uses: actions/checkout@v4
# 포터블 CI 와 달리 여기서는 torch CUDA 빌드가 필요하다.
# 4~5티어(Seed-X / Qwen3)가 torch 위에서 돌기 때문이다.
- name: 의존성 설치 (torch CUDA 포함)
shell: pwsh
run: |
python -m venv .venv-gpu
.\.venv-gpu\Scripts\python -m pip install --upgrade pip --quiet
.\.venv-gpu\Scripts\python -m pip install torch --index-url https://download.pytorch.org/whl/cu128
.\.venv-gpu\Scripts\python -m pip install -r requirements.txt
.\.venv-gpu\Scripts\python -c "import torch; assert torch.cuda.is_available(), 'CUDA 안 잡힘 — CPU 빌드가 깔렸는지 확인'; print('CUDA OK:', torch.cuda.get_device_name(0))"
# 4티어(Seed-X AWQ Int4)에만 필요하고 Windows 휠이 없을 때가 있다.
# 실패해도 멈추지 않는다 — 점검기가 autoawq 유무를 보고 4티어를
# 건너뛰면서 "건너뜀: 정밀(autoawq 없음)" 이라고 분명히 남긴다.
- name: autoawq (4티어 전용, 선택)
shell: pwsh
continue-on-error: true
run: .\.venv-gpu\Scripts\python -m pip install autoawq
- name: GPU 전용 점검
shell: pwsh
env:
PYTHONPATH: src
run: .\.venv-gpu\Scripts\python packaging\windows_smoke.py --gpu
- name: 결과물 업로드
uses: actions/upload-artifact@v4
if: always()
with:
name: LiveSub-gpu-report
path: artifacts/*.txt
retention-days: 14

View File

@@ -0,0 +1,116 @@
# Windows 검증 — Gitea Actions
#
# main 에 push 하거나 Actions 탭에서 수동 실행하면 Windows 러너가 자동으로
# 1) 테스트를 돌리고
# 2) 포터블 exe 를 빌드하고
# 3) 앱이 실제로 뜨는지 확인한 뒤
# 4) exe 와 스크린샷을 artifact 로 올립니다.
#
# 러너 등록 방법은 docs/WINDOWS-TESTING.md 참고.
# 러너가 없으면 이 워크플로는 그냥 대기 상태로 남고 아무 일도 하지 않습니다.
name: Windows
on:
push:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: windows
timeout-minutes: 60
steps:
# Gitea 는 runs-on 이 어떤 러너 라벨과도 안 맞으면 작업을 실패시키지 않고
# 기본 ubuntu 컨테이너에서 돌린다. 라벨을 잘못 등록하면 Windows 검증이
# 조용히 리눅스에서 돌아가 "통과했다"는 착각을 준다. 그래서 먼저 막는다.
#
# bash 가 아니라 pwsh 를 쓴다. Windows 에 Git Bash 가 깔려 있어도
# bash.exe 는 기본 PATH 에 없어서 (Git\cmd 만 등록된다) 첫 단계부터
# "bash not found" 로 죽는다. 라벨을 잘못 걸어 리눅스 컨테이너로
# 떨어진 경우에는 pwsh 가 없어서 역시 실패하므로, 어느 쪽이든
# 조용히 통과하는 일은 없다.
- name: Windows 에서 돌고 있는지 확인
shell: pwsh
run: |
Write-Host "RUNNER_OS=$env:RUNNER_OS"
if ($env:RUNNER_OS -ne "Windows") {
Write-Host "::error::Windows 러너가 아닙니다 ($env:RUNNER_OS)."
Write-Host "::error::러너를 'windows:host' 라벨로 등록했는지 확인하세요."
exit 1
}
- name: 소스 받기
uses: actions/checkout@v4
- name: Python 준비
shell: pwsh
run: |
python --version
python -m venv .venv-ci
.\.venv-ci\Scripts\python -m pip install --upgrade pip --quiet
# torch 없이 설치한다. 포터블 빌드와 같은 조건을 만들어야
# "포터블에서 1~3티어가 된다"는 주장을 실제로 검증할 수 있다.
- name: 의존성 설치 (포터블 조건 = torch 없음)
shell: pwsh
run: |
.\.venv-ci\Scripts\python -m pip install -r packaging\requirements-portable.txt
.\.venv-ci\Scripts\python -m pip install pytest ruff
.\.venv-ci\Scripts\python -c "import importlib.util; assert importlib.util.find_spec('torch') is None; print('torch 없음 - 포터블 조건 OK')"
- name: 테스트
shell: pwsh
env:
PYTHONPATH: src
run: |
.\.venv-ci\Scripts\python -m pytest tests -q
.\.venv-ci\Scripts\python -m ruff check src tests scripts
# 여기부터가 리눅스에서 못 하던 것들이다.
- name: Windows 전용 경로 확인 (오디오 장치·단축키·프로세스 열거)
shell: pwsh
env:
PYTHONPATH: src
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py
# 부품이 다 통과해도 이어 붙이면 안 되는 경우가 있다. 실제 음성 파일을
# 넣어 한국어 자막이 나오는지 끝까지 확인한다. CPU 로 돌므로 GPU 없는
# 서버 VM 에서도 그대로 된다. 모델은 처음 한 번만 받고 이후 캐시를 쓴다.
- name: 전 파이프라인 (오디오 -> 자막)
shell: pwsh
timeout-minutes: 30
env:
PYTHONPATH: src
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --pipeline
- name: 프로그램별 캡처 보조 프로그램 빌드
shell: pwsh
continue-on-error: true # VS Build Tools 가 없는 러너도 있을 수 있다
run: |
cmake -S native\process_loopback -B native\process_loopback\build -A x64
cmake --build native\process_loopback\build --config Release
- name: 포터블 exe 빌드
shell: pwsh
run: |
.\.venv-ci\Scripts\python -m pip install pyinstaller
.\.venv-ci\Scripts\python -m PyInstaller packaging\livesub.spec --noconfirm
$exe = Get-Item dist\LiveSub.exe
"빌드 완료: {0:N1} MB" -f ($exe.Length / 1MB)
- name: 빌드된 exe 가 실제로 실행되는지
shell: pwsh
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --exe dist\LiveSub.exe
- name: 결과물 업로드
uses: actions/upload-artifact@v4
if: always()
with:
name: LiveSub-portable
path: |
dist/LiveSub.exe
artifacts/*.png
artifacts/*.txt
retention-days: 14

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
venv/
.pytest_cache/
.ruff_cache/
# 네이티브 빌드 산출물
#
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
native/process_loopback/build/
src/livesub/resources/bin/*.exe
src/livesub/resources/bin/*.pdb
# 모델 캐시 / 학습 산출물 / 사용자 데이터
#
# ⚠️ 반드시 슬래시로 시작해 저장소 루트에 고정할 것. `models/` 로 쓰면 깊이에
# 상관없이 같은 이름의 폴더를 전부 먹는다. 실제로 이것 때문에 소스 패키지
# src/livesub/models/ 8개 파일이 한 번도 커밋되지 않았고, Gitea 에서 clone 한
# 쪽은 앱이 아예 임포트되지 않았다. Windows CI VM 첫 검증에서 잡혔다.
/models/
/adapters/
/data/
*.jsonl
config.json
glossary.json
transcripts/
*.log
# 에디터
.vscode/
.idea/
*.swp
.DS_Store
artifacts/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 tkrmagid
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

326
README.md
View File

@@ -1,2 +1,326 @@
# live-app-translator
# LiveSub — 게임 소리를 실시간 자막으로
![LiveSub](docs/images/banner.png)
게임이나 프로그램에서 나오는 소리를 실시간으로 받아 번역해, 화면 위에 자막으로 얹어줍니다.
디스코드 오버레이처럼 동작하고, 번역은 전부 **내 컴퓨터의 GPU에서** 돌아갑니다.
인터넷도, API 키도 필요 없습니다.
한국어 · English · 日本語 · 中文 사이를 번역합니다.
---
## 무엇을 하는 프로그램인가
게임을 하는데 영어 음성만 나올 때, 해외 방송을 보는데 자막이 없을 때,
그 프로그램을 골라서 `번역 시작`만 누르면 화면에 한국어 자막이 뜹니다.
```
게임 소리 → 음성인식 → 번역 → 화면 위 자막
(Whisper) (Seed-X / NLLB)
```
![홈 화면](docs/images/home.png)
## 주요 기능
- **프로그램 단위 소리 캡처** — 게임 소리만 받고 디스코드 음성은 안 받습니다
- **마이크 입력도 가능** — 홈 화면에서 목록을 입력 장치로 바꾸면 통화 상대나 내 목소리를 번역합니다
- **게임과 같이 써도 안 무겁게** — GPU를 얼마나 양보할지 직접 정합니다 (아래 참고)
- **디스코드식 자막 배치** — 모니터를 고르고 9칸 중 하나를 누르면 그 자리에 붙습니다
- **평소엔 잠겨 있는 자막** — 게임 중에 실수로 끌려가지 않습니다. `자막 위치 조정`을 켜면 옮기고 모서리로 크기를 바꿀 수 있고, 크기에 맞춰 글자도 같이 커집니다
- **전역 단축키** — 게임 중에 `Ctrl+Alt+S` 로 자막을 껐다 켭니다
- **게임별 기본 용어집 내장** — 옵치2·워독스·배그·R6·롤·발로란트·에이펙스 등 **540개** (필수 게임은 기본 켜짐)
- **5단계 품질 선택** — 속도 우선(0.6초)부터 품질 우선까지. GPU가 없으면 CPU에서 실제로 돌아가는 티어를 추천합니다
- **자막 자유 설정** — 글꼴·크기·색·외곽선·투명도·줄 수·배경 여백 (배경은 글자를 감싸는 크기로 그려집니다)
- **추가학습** — 게임/방송 말투로 번역 모델을 LoRA 학습시킬 수 있습니다
- **존댓말 / 반말 선택** — 기본은 존댓말. 원문이 실제로 존댓말이면 반말 모드여도 존댓말을 지킵니다
- **포터블 exe** — 설치 없이 파일 하나로 실행 (아래 참고)
- **완전 로컬** — 음성이 외부로 나가지 않습니다
---
## 게임과 같이 쓰기 (GPU 절약)
게임이 먼저입니다. `설정` 화면의 **저부하 모드**(기본 켜짐)가 이렇게 동작합니다.
| 항목 | 기본값 | 하는 일 |
|---|---|---|
| 연산 정밀도 | int8 | VRAM 사용량과 연산량을 줄입니다 |
| VRAM 상한 | 35% | 게임이 쓸 VRAM을 먼저 확보합니다 |
| GPU 양보 시간 | 60ms | 문장 하나 처리 후 GPU를 잠깐 놓습니다 |
| 유휴 시 모델 해제 | 300초 | 조용하면 모델을 내려 VRAM을 통째로 반납합니다 |
| 중간 결과 | 꺼짐 | 같은 오디오를 두 번 인식하지 않습니다 |
8GB GPU 기준으로 자막이 약 2.8GB만 쓰고 나머지 5GB 이상을 게임에 남깁니다.
게임이 여전히 버벅이면 VRAM 상한을 낮추거나 모델 티어를 `1. 번개`로 내리세요.
![설정 화면](docs/images/settings.png)
## 말투 — 존댓말 / 반말
홈 화면에서 고릅니다. **기본은 존댓말**입니다.
| 상황 | 존댓말 모드 | 반말 모드 |
|---|---|---|
| 영어·중국어 원문 (높임 없음) | 존댓말 | 반말 |
| 일본어 원문이 `です/ます` | 존댓말 | **존댓말** |
| 일본어 원문이 반말 | 존댓말 | 반말 |
**원문이 실제로 존댓말이면 반말 모드여도 존댓말로 나옵니다.** 상대가 정중하게
말했는데 자막이 반말이면 뉘앙스가 통째로 뒤집히기 때문입니다.
영어·중국어는 문법적 높임이 없어서 항상 고른 모드를 따릅니다.
```
Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
반말 → 적이 왼쪽에서 와
左から来ます (정중) 존댓말 → 적이 왼쪽에서 옵니다
반말 → 적이 왼쪽에서 옵니다 ← 원문이 존댓말
```
## 자막 위치 잡기
`자막` 화면에서 **모니터를 고르고 네모칸을 누르면** 그 자리에 붙습니다.
디스코드 오버레이와 같은 방식입니다. 미세 조정은 자막을 직접 드래그하면 되고,
그러면 그 위치를 그대로 기억합니다.
![자막 화면](docs/images/subtitle.png)
| 단축키 | 동작 |
|---|---|
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 |
| `Ctrl+Alt+D` | 번역 시작 / 정지 |
전역 단축키라 **게임 창이 떠 있어도 동작합니다** (Windows 전용). `설정`에서 바꿀 수 있습니다.
---
## 설치 — 둘 중 하나
| | 포터블 (권장) | 일반 설치 |
|---|---|---|
| 준비물 | `LiveSub.exe` 하나 | Python + PyTorch |
| 크기 | 약 500~700MB | 약 3GB |
| 쓸 수 있는 티어 | 1~3 | 1~5 전부 |
| 번역 품질 | NLLB | NLLB + Seed-X / Qwen3 |
포터블은 PyTorch를 빼서 만듭니다 (토크나이저용 `transformers`는 포함). PyTorch+CUDA만 2.5GB라 한 파일로 묶으면
**실행할 때마다** 그걸 임시폴더에 푸느라 1분 넘게 걸리기 때문입니다.
음성인식도 번역도 CTranslate2 위에서 돌아가 PyTorch가 필요 없으므로,
1~3티어는 포터블에서 그대로 다 됩니다.
### 포터블 만들기
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
```
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
고른 폴더**로 내려받습니다.
---
## 일반 설치
### 1. 사전 준비
- Windows 10 (2004 이상) 또는 Windows 11
- NVIDIA GPU (권장 VRAM 6GB 이상) + 최신 드라이버
- Python 3.10 ~ 3.12
### 2. PyTorch (CUDA 빌드) 먼저
CPU 버전이 깔리면 GPU를 못 씁니다. 반드시 이 순서로 설치하세요.
```powershell
py -3.12 -m venv .venv
.venv\Scripts\activate
# Blackwell(RTX 50 시리즈)은 cu128, 그 이전 세대는 cu124
pip install torch --index-url https://download.pytorch.org/whl/cu128
```
### 3. 나머지 설치
```powershell
pip install -r requirements.txt
pip install -e .
```
### 4. 프로그램별 캡처 켜기 (선택, 권장)
이걸 빌드하지 않으면 출력 장치 전체 소리를 받습니다 (다른 앱 소리도 섞임).
```powershell
# Visual Studio 2022 Build Tools (C++ 데스크톱) + CMake 필요
winget install Microsoft.VisualStudio.2022.BuildTools
winget install Kitware.CMake
powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
```
### 5. 실행
```powershell
livesub
```
또는 `python -m livesub`
---
## 처음 쓸 때
### 먼저 저장 위치를 고릅니다
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
전부 들어갑니다.
![저장 위치 선택](docs/images/first-run.png)
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
![위치 변경](docs/images/change-location.png)
### 그다음
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
4. **자막** 화면에서 모니터와 위치를 정합니다
5. `번역 시작` — 또는 게임 중에 `Ctrl+Alt+D`
모델 티어 기본값은 `2. 신속`입니다. 게임과 같이 돌리기 좋은 지점이라 그렇게 잡았고,
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 처음 켤 때 고른 그 폴더입니다.
![모델 화면](docs/images/models.png)
---
## 모델 티어
| # | 이름 | VRAM | 지연 | 설명 |
|---|---|---|---|---|
| 1 | 번개 | 2GB | 0.6초 | 속도 최우선. 저사양 GPU |
| 2 | **신속** | 4GB | 0.9초 | **기본값.** 인식률은 최상급, 번역만 경량 |
| 3 | 균형 | 6GB | 1.2초 | 게임을 안 켜고 볼 때 무난 |
| 4 | **정밀** | 10GB | 2.0초 | **추천.** 현 시점 최고 번역 품질 |
| 5 | 극한 | 16GB | 3.0초 | 추가학습(LoRA)에 최적 |
어떤 모델을 왜 골랐는지는 [docs/MODELS.md](docs/MODELS.md)에 정리했습니다.
---
## 용어집
게임 고유명사가 이상하게 번역될 때 씁니다. **학습이 필요 없고 즉시 적용됩니다.**
### 기본 제공 용어집
**처음 설치하면 `FPS 공통 · 오버워치2 · 배그 · R6 · 워독스`가 이미 켜져 있습니다.**
안 하는 게임은 체크를 끄면 됩니다.
| 팩 | 개수 | 내용 |
|---|---|---|
| 리그 오브 레전드 | 74 | 바론·갱·억제기·한타·오브젝트 콜 |
| 레인보우 식스 시즈 | 67 | 보강·하드브리치·앵커/로머·디퓨저·파밸 |
| 배틀그라운드 | 65 | 자기장·어부·파밍·낙하·스쿼드 콜 |
| 발로란트 | 59 | 스파이크·설치·해체·이코·리테이크 |
| 워치독스 | 59 | 데드섹·ctOS·블룸·해킹/잠입 용어 |
| 오버워치 2 | 49 | 궁 게이지·거점·화물·역할군 |
| FPS 공통 | 46 | 장르 전반의 총기·교전·파티 콜 |
| 에이펙스 레전드 | 45 | 링·배너·리스폰 비컨·스쿼드 콜 |
| 마인크래프트 | 38 | 블록·몹·차원 이름 (한국어판 공식 번역어) |
| 방송·스트리밍 | 38 | 후원·구독·클립·메타 등 방송 말투 |
#### 여러 팩을 같이 켜면
적용 순서는 **장르 공통 → 게임 전용 → 내가 등록한 것** 입니다. 뒤에 오는 쪽이 이기므로
`knocked`는 배그를 켰으면 항상 "눕혔다"(배그)가 되고, 체크한 순서에 휘둘리지 않습니다.
게임 전용 팩끼리 역어가 갈리는 경우(예: `payload`가 오버워치는 "화물", 워독스는 "페이로드")는
용어집 화면이 어떤 단어인지 알려줍니다. 안 하는 게임을 끄거나, 원하는 역어를 직접
등록하면 됩니다.
![용어집 화면](docs/images/glossary.png)
### 직접 추가하기
`용어집` 화면 표에 입력하거나 CSV로 가져오세요.
**같은 단어를 직접 등록하면 기본 팩보다 항상 우선합니다.**
```csv
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
Nexus,넥서스,Nexus,ネクサス,基地,LoL
baron,바론,Baron,バロン,男爵,LoL
```
## 추가학습
말투나 문장 구조까지 바꾸고 싶다면 LoRA로 학습시킬 수 있습니다.
언어쌍당 문장 1,000~3,000개가 필요합니다.
```powershell
pip install -e ".[finetune]"
python scripts\finetune_mt.py --data data\game.jsonl --tier ultimate --output .\adapters\game-ko
```
데이터를 어떻게 모으고 어느 모델을 학습시켜야 하는지는
[docs/FINETUNING.md](docs/FINETUNING.md)를 참고하세요.
---
## 자막 창 조작
| 동작 | 결과 |
|---|---|
| `Ctrl+Alt+S` | 자막 켜기 / 끄기 (게임 중에도 동작) |
| 드래그 | 위치 이동 (자유 배치로 전환) |
| 우하단 모서리 드래그 | 크기 조절 |
| 마우스 휠 | 글자 크기 |
| 우클릭 | 잠금 / 클릭 통과 / 항상 위에 / 숨기기 |
클릭 통과는 기본으로 켜져 있어, 자막 위를 클릭해도 게임으로 전달됩니다.
위치를 옮기려면 `자막` 화면에서 클릭 통과를 잠깐 끄세요.
전체화면 게임에서 자막이 안 보이면 게임을 **테두리 없는 창 모드**로 바꾸세요.
독점 전체화면(exclusive fullscreen)에서는 어떤 오버레이도 표시되지 않습니다.
---
## 개발
```bash
pip install -e ".[dev]"
pytest # GPU·오디오 장치 없이 실행됩니다
ruff check src tests scripts
```
구조는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)를 보세요.
### Windows 자동 검증
개발은 리눅스에서 하므로 오디오 캡처·전역 단축키·exe 빌드는 여기서 테스트할 수
없습니다. Windows 러너를 붙이면 push할 때마다 자동으로 확인되고 exe와
스크린샷이 artifact로 올라옵니다 — [docs/WINDOWS-TESTING.md](docs/WINDOWS-TESTING.md).
러너 없이 그때그때 확인만 하려면:
```powershell
.\.venv\Scripts\python packaging\windows_smoke.py
```
## 라이선스
MIT. 사용하는 모델은 각자의 라이선스를 따릅니다
(Whisper: MIT, NLLB: CC-BY-NC, Seed-X: OpenMDW, Qwen3: Apache-2.0).
**NLLB는 비상업적 이용만 허용됩니다.** 상업적으로 쓰려면 4~5티어를 사용하세요.

154
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,154 @@
# 구조
## 데이터 흐름
```
[캡처 스레드] [분할 스레드] [추론 스레드] [GUI 스레드]
livesub_capture.exe → Segmenter (VAD) → Whisper → 번역 모델 → 자막 오버레이
또는 WASAPI 발화 단위로 절단 용어집 적용 컨트롤 창 로그
↓ ↓ ↓ ↑
오디오 큐 구간 큐(최대 32) TranslationLine Qt Signal
(16kHz mono f32) 확정 / 중간결과
```
세 워커 스레드는 모두 큐로만 연결된다. 어느 한 단계가 밀려도 나머지가 멈추지 않는다.
큐가 가득 차면 **오래된 오디오를 버린다** — 실시간 자막에서는 밀린 소리보다 지금 나는
소리가 항상 더 가치 있기 때문이다.
## 패키지
```
src/livesub/
constants.py 전역 상수, 언어 표, 경로
config.py 설정 dataclass + JSON 영속화
app.py 진입점
audio/
base.py CaptureBackend 추상 클래스, 리샘플링
process_loopback.py 프로그램별 캡처 (네이티브 보조 프로그램 구동)
wasapi_loopback.py 출력 장치 전체 캡처 (PyAudioWPatch)
file_source.py WAV 재생 (테스트·데모용)
segmenter.py 적응형 VAD로 발화 구간 절단
models/
tiers.py 5단계 티어 + 프롬프트 형식 정의
asr.py faster-whisper 래퍼
translator.py CTranslate2 / Transformers 두 백엔드
glossary.py 용어집 (플레이스홀더 보호 + 프롬프트 주입)
packs.py 게임별 기본 용어집 팩 로드·병합
manager.py 티어별 모델 쌍 로드·해제, GPU 탐지, VRAM 상한
core/
engine.py 파이프라인 오케스트레이션 (Qt 비의존)
events.py TranslationLine, EngineStatus
ui/
theme.py 디자인 토큰 + QSS
overlay.py 자막 창 + 공용 렌더링 함수
placement.py 모니터 목록 + 9분할 배치 계산 (Qt 비의존)
hotkeys.py 전역 단축키 (Windows RegisterHotKey)
main_window.py 사이드바 + 페이지 스택
pages/ 홈 / 모델 / 자막 / 용어집 / 설정
widgets/ Card, StatusPill, LevelMeter, AnchorGrid
resources/glossaries/ 게임별 기본 용어집 JSON (롤·발로란트·옵치2·마크 등)
native/process_loopback/ WASAPI process loopback C++ 보조 프로그램
scripts/finetune_mt.py LoRA 추가학습
```
## 설계 판단
### 캡처를 별도 실행 파일로 뺀 이유
WASAPI의 `ActivateAudioInterfaceAsync` + `AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK`
은 COM 비동기 콜백을 요구한다. ctypes로 COM vtable을 흉내 내는 것보다 C++ 200줄이
훨씬 안전하고 디버깅이 쉽다. 파이프로 생 PCM만 주고받으므로 인터페이스도 단순하다.
보조 프로그램이 없으면 `capture_capabilities()` 가 이를 알려주고 장치 루프백으로
자동 폴백한다. 빌드 없이도 프로그램은 동작한다.
### 게임이 먼저다
이 프로그램은 게임 위에 얹혀 도는 것이 기본 용도라, 자막 품질보다 게임 프레임을
지키는 쪽이 대체로 더 중요하다. 저부하 모드는 네 방향에서 GPU를 양보한다.
1. **연산 정밀도 강등** (`ModelManager.effective_*_spec`) — float16 → int8.
티어 정의 자체를 건드리지 않고 파생 사양만 바꾸므로, 모드를 끄면 즉시 원복된다.
2. **VRAM 상한** (`apply_vram_limit`) — 모델을 올리기 *전에* 걸어야 실제로 먹는다.
게임과 동시에 돌 때 프레임을 죽이는 주범은 연산 시간이 아니라 VRAM 고갈이다.
게임이 쓸 메모리를 우리가 먹으면 텍스처 스트리밍이 시스템 메모리로 밀려난다.
3. **추론 후 양보** — 문장 하나를 처리하고 수십 ms 쉰다. 연속 발화 때 GPU를
독점하지 않기 위해서다.
4. **유휴 언로드** — 조용한 구간이 길어지면 모델을 통째로 내린다. 그래서
`_infer_loop` 은 모델 참조를 캐시하지 않고 매번 `models.recognizer()` 를 부른다.
### 중간 결과는 번역하지 않는다
말하는 도중에도 인식 결과를 흐리게 보여주면 체감 지연이 크게 줄어든다. 하지만
매번 번역까지 돌리면 GPU가 몇 배로 바빠지고, 문장이 완성되기 전 번역은 어차피
틀린다. 그래서 **중간 결과는 원문만, 확정 문장만 번역**한다.
### 용어집 팩은 계층으로 겹친다
여러 게임 팩을 동시에 켜면 같은 단어의 역어가 갈린다 (`knocked`는 FPS 공통에서
"기절", 배그에서 "눕혔다"). 단순히 켠 순서대로 덮어쓰면 체크박스를 누른 순서가
결과를 바꾸는데, 사용자는 그 순서를 볼 수도 바꿀 수도 없다.
그래서 팩에 `scope`("generic" / "game")를 두고 **범용 → 게임 전용 → 사용자 항목**
순으로 적용한다. 게임 전용이 항상 범용을 이기므로 결과가 결정적이다.
게임 팩끼리 갈리는 것은 사람이 판단할 문제라, `find_conflicts()` 로 뽑아 UI에
그대로 보여준다.
데이터 쪽 함정도 하나 있다. 용어 목록은 최장일치를 위해 길이 내림차순으로
정렬하지만, 이는 **같은 시작 위치**에서만 통한다. "the blue"(조각)가 등록돼
있으면 "the blue zone"에서 더 앞 위치라는 이유로 "blue zone"을 이겨 "자기장
자기장"이 된다. `test_each_term_protects_as_a_single_unit` 이 모든 팩을 켠
상태로 이 겹침을 잡는다.
### 용어집은 실제 모델로 검증해야 한다
이 기능은 단위 테스트만으로는 검증되지 않는다. "번역 모델이 자리표시자를
그대로 통과시킨다"는 전제 자체가 틀릴 수 있기 때문이다. 실제 NLLB 를 돌려
보고 나서야 드러난 것들:
1. **자리표시자 형식.** 처음 쓰던 `⟦0⟧` 는 NLLB 가 괄호를 통째로 날려
`0` 만 남겼다(생존률 0/3). 후보 8종을 실측해 `#0#` 로 바꿨다(3/3).
그전까지 용어가 자막에서 그냥 사라지고 있었다.
2. **소실 대비.** 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도
사라진다. 그대로 복원하면 용어가 증발하는데 이건 용어집을 안 쓴 것보다
나쁘다. 그래서 하나라도 사라지면 보호 없이 한 번 더 번역한다.
3. **서술어는 문장 전체일 때만.** "need ammo" 를 문장 한가운데서 치환하면
모델이 문법을 세울 근거를 잃어 "탄 필요해와 구급상자" 가 된다. 명사만
바꾸면 "탄약과 구급상자가 필요합니다" 로 제대로 나온다. 그래서 절·서술어
항목 56개는 `whole_only` 로 두어 그 말만 단독으로 나왔을 때만 적용한다.
4. **조사 어긋남.** 역어의 받침이 자리표시자와 다르면 "자기장를" 이 남는다.
받침은 한글 코드에서 바로 읽히므로 `fix_particles()` 로 정확히 고친다.
### 배치 계산을 Qt에서 떼어냈다
`placement.py` 는 `ScreenInfo` 라는 순수 데이터만 받아 좌표를 계산한다. 덕분에
모니터가 없는 CI 환경에서도 "두 번째 모니터 하단 중앙", "음수 좌표 모니터",
"화면보다 큰 자막 창" 같은 경우를 전부 테스트할 수 있다 (`tests/test_placement.py`).
자막이 화면 밖으로 나가 영영 안 보이는 사고는 여기서 막는다.
### UI 코드와 렌더링 코드 분리
자막 설정 미리보기와 실제 오버레이가 `paint_subtitle()` 하나를 공유한다.
QSS로 미리보기를 흉내 내면 `text-shadow` 미지원 같은 이유로 실제와 어긋난다.
### 엔진은 Qt를 모른다
`core/engine.py` 는 콜백만 받는다. Qt 의존은 `MainWindow` 가 콜백을 Signal로
다시 던지는 지점에만 있다. 덕분에 GPU도 오디오 장치도 없는 환경에서 파이프라인
전체를 테스트할 수 있다 (`tests/test_pipeline.py`).
## 테스트
`pytest` 전체가 GPU·오디오 장치 없이 돈다.
- `test_segmenter.py` — 합성 사인파로 VAD 절단 검증
- `test_pipeline.py` — 가짜 캡처/인식/번역으로 엔진 종단 검증
- `test_glossary.py` — 최장일치, 단어경계, 왕복 변환
- `test_config.py` — 스키마 변경 내성
- `test_ui_smoke.py` — offscreen 렌더링으로 화면 생성 및 자막 픽셀 검증

142
docs/FINETUNING.md Normal file
View File

@@ -0,0 +1,142 @@
# 게임·방송 용어 추가학습
## 결론부터
**가능합니다.** 다만 대부분의 경우 **추가학습보다 용어집이 먼저입니다.**
| 방법 | 준비 | 효과가 나타나는 시점 | 무엇에 좋은가 |
|---|---|---|---|
| **1. 용어집** (구현 완료) | 단어 목록만 | **즉시** | 고유명사, 스킬명, 아이템명, 캐릭터명 |
| **2. LoRA 추가학습** | 문장 쌍 1,000~3,000개 | 30분~3시간 학습 | 말투, 문장 구조, 도메인 어조 |
| **3. 풀 파인튜닝** | 문장 쌍 10,000개 이상 | 수 시간~하루 | 번역 스타일 전면 교체 |
용어집과 추가학습은 **경쟁 관계가 아니라 보완 관계**입니다. 둘 다 켜는 게 가장 좋습니다.
---
## 1. 용어집 — 먼저 이것부터
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
동작 방식은 번역 모델에 따라 다릅니다.
- **1~4티어 (NLLB, Seed-X)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다.
- **5티어 (Qwen3)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙어 더 자연스럽습니다.
4티어 Seed-X가 LLM인데도 프롬프트 방식을 못 쓰는 이유는, 이 모델이 chat template 없는
**번역 전용 completion 모델**이기 때문입니다. 지시문을 이해하지 못할뿐더러 모델 카드가
정한 고정 프롬프트 형식(끝의 `<ko>` 같은 언어 태그 포함)을 벗어나면 품질이 무너집니다.
용어집을 문맥까지 자연스럽게 반영하고 싶다면 5티어를 쓰세요.
CSV로 한 번에 가져올 수 있습니다.
```csv
원문 용어,한국어 역어,English 역어,日本語 역어,中文 역어,메모
Nexus,넥서스,Nexus,ネクサス,基地,LoL
baron,바론,Baron,バロン,男爵,LoL
ult,궁,ultimate,アルティメット,大招,궁극기
gg,잘 싸웠다,gg,gg,打得好,
```
**게임 하나당 200~500개만 등록해도 체감 품질이 크게 올라갑니다.** 추가학습으로
같은 효과를 내려면 훨씬 많은 데이터가 필요합니다. 용어집을 먼저 채우세요.
---
## 2. LoRA 추가학습 — 용어집으로 안 되는 것
용어집은 "이 단어를 저 단어로"만 고칩니다. 아래는 못 잡습니다.
- 말투 — "You're getting rolled" → "탈탈 털리고 있네" (직역 아닌 게임 말투)
- 생략된 주어·목적어 복원 — 게임 음성에는 생략이 많습니다
- 방송 특유의 감탄·리액션 어조
이건 문장 쌍으로 학습시켜야 합니다.
### 필요한 데이터 양
공개 연구 기준으로,
- 언어쌍당 **약 650~1,000 문장**만으로도 LoRA 도메인 적응이 동작합니다.
- 언어쌍당 **2,000 문장 / 15~20 epoch** 수준에서 chrF++ 평균 **+6.1점** 개선이 보고됩니다.
즉 **언어쌍당 1,000~3,000 문장이 현실적인 목표**입니다. 4개 언어 전부가 아니라,
실제로 많이 쓰는 방향(예: 영어→한국어) 하나만 먼저 하는 게 효율적입니다.
### 데이터 만드는 법
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
형식은 JSONL 한 줄에 한 쌍입니다.
```jsonl
{"source": "Enemy missing from mid", "target": "미드 실종", "source_lang": "en", "target_lang": "ko"}
{"source": "I'll take baron", "target": "바론 내가 먹을게", "source_lang": "en", "target_lang": "ko"}
```
### 학습 실행
```bash
pip install -e ".[finetune]"
python scripts/finetune_mt.py \
--data data/game_terms.jsonl \
--tier ultimate \
--output ./adapters/game-ko \
--epochs 3
```
끝나면 `모델` 화면의 **추가학습 어댑터** 칸에 `./adapters/game-ko` 를 넣으면 적용됩니다.
### 티어별 난이도
| 티어 | 번역 모델 | 방식 | 학습에 필요한 VRAM | 난이도 |
|---|---|---|---|---|
| 1~2 | NLLB-600M | 풀 파인튜닝 가능 | ~8GB | ★ 쉬움 |
| 3 | NLLB-1.3B | LoRA | ~10GB | ★★ |
| 4 | Seed-X-PPO-7B | LoRA (권장 안 함) | ~16GB | ★★★ 까다로움 |
| 5 | **Qwen3-8B** | **LoRA** | **~16GB** | **★ 쉬움 (권장)** |
**4티어 Seed-X는 추가학습 대상으로 권하지 않습니다.** 이미 PPO(강화학습)까지 마친
모델이라 그 위에 SFT를 얹으면 기존 번역 품질이 무너지기 쉽습니다 (catastrophic forgetting).
Int4 양자화 가중치라 학습 자체도 번거롭습니다.
**추가학습을 할 거라면 5티어(Qwen3-8B)**, **VRAM이 부족하면 1~2티어(NLLB-600M 풀 파인튜닝)**
로 가는 게 맞습니다.
### 권장 하이퍼파라미터
연구에서 널리 쓰이는 설정입니다. `scripts/finetune_mt.py` 의 기본값이기도 합니다.
```
LoRA rank r = 16, alpha = 32, dropout = 0.05
대상 모듈: attention 의 q_proj, v_proj
learning rate = 2e-4 (LoRA) / 5e-5 (풀 파인튜닝)
epochs = 3 (데이터 2,000개 이상) / 10~20 (수백 개)
```
---
## 3. 어느 것부터 할지
```
1주차 용어집 300개 등록 → 이것만으로 충분한지 확인
2주차 번역 기록 켜고 실사용, 틀린 문장 수집
3주차 고친 문장 1,000개 모이면 LoRA 학습
```
**용어집만으로 만족스러우면 추가학습은 안 해도 됩니다.** 실제로 고유명사 오역이
체감 불만의 대부분입니다.
## 참고
- [Fine-Tuning NLLB-200 with LoRA on a 650-Sentence Corpus](https://medium.com/@meinnps/fine-tuning-nllb-200-with-lora-on-a-650-sentence-turkmen-english-corpus-082f68bdec71)
- [SemiAdapt / SemiLoRA: Efficient Domain Adaptation for Low-Resource MT (arXiv)](https://arxiv.org/pdf/2510.18725)
- [How to fine-tune a NLLB-200 model](https://cointegrated.medium.com/how-to-fine-tune-a-nllb-200-model-for-translating-a-new-language-a37fc706b865)

105
docs/MODELS.md Normal file
View File

@@ -0,0 +1,105 @@
# 모델 선정 근거
## 왜 두 단계인가
"소리 → 번역"을 한 모델로 끝내는 방법도 있다. Whisper에는 `translate` 태스크가 있고
Voxtral·Qwen-Omni 같은 음성 LLM도 있다. 하지만 이 프로그램에는 맞지 않는다.
- Whisper의 `translate`는 **X → 영어만** 된다. 한국어로 받아볼 수 없다.
- 음성 LLM 한 방에 처리하면 번역만 따로 교체하거나 추가학습시킬 수 없다.
- 용어집을 꽂아 넣을 지점이 사라진다.
그래서 **음성인식(ASR) → 번역(MT)** 2단 구조로 간다. 두 단계를 따로 고를 수 있어서
"인식은 최고급, 번역은 경량" 같은 조합이 가능하고, 이게 실제로 가장 가성비가 좋다.
## 음성인식
| 후보 | 판단 |
|---|---|
| **Whisper large-v3-turbo** | 디코더를 32층 → 4층으로 줄여 large-v3 대비 약 6배 빠르면서 정확도 손실은 1~2%. 809M 파라미터, int8이면 VRAM 1.6GB. **한/영/일/중 모두 지원.** |
| Whisper large-v3 | 가장 정확하지만 느리다. 최상위 티어에만. |
| distil-whisper | 빠르지만 **영어 전용**이라 탈락. |
| NVIDIA Parakeet | 실시간성은 최고지만 **영어 전용**이라 탈락. |
실행은 `faster-whisper`(CTranslate2)로 한다. 순정 `openai-whisper` 대비 4배 빠르고
VRAM을 절반만 쓴다.
→ **2~4티어의 기본은 large-v3-turbo.** 4개 언어를 모두 지원하면서 실시간을 만족하는
유일한 지점이다.
## 번역
| 후보 | VRAM | 판단 |
|---|---|---|
| **Seed-X-PPO-7B** (ByteDance) | ~5.6GB (Int4) | 28개 언어 **번역 전용**으로 학습된 7B. 자체 평가에서 Gemma3-27B, Llama4-Scout, Qwen3-235B를 앞서고 사람 평가에서 GPT-4o·Claude-3.5·Gemini-2.5-Pro와 대등. 한/영/일/중이 모두 주력 언어. |
| **Qwen3-8B** | ~11GB (fp16) | 범용 LLM이라 Seed-X보다 기본 번역은 약간 아래. 대신 **추가학습(LoRA) 생태계가 가장 두껍고**, 프롬프트 지시를 잘 따른다. |
| **NLLB-200-distilled 600M / 1.3B** | 0.8~2.9GB | 200개 언어 seq2seq. 구어체는 LLM보다 딱딱하지만 압도적으로 빠르고 가볍다. 게다가 **풀 파인튜닝이 소비자 GPU에서 된다.** |
| Gemma 3 12B | ~8GB | 140개 언어로 넓게 강하지만, 한/영/일/중만 필요한 우리에게는 Seed-X가 더 낫다. |
| opus-mt (Helsinki) | ~0.3GB | 언어쌍마다 모델이 따로라 4×3=12개를 관리해야 한다. 티어 구조와 안 맞아 탈락. |
## 다섯 티어
| # | 이름 | 음성인식 | 번역 | VRAM | 지연 |
|---|---|---|---|---|---|
| 1 | 번개 | whisper-small (int8) | NLLB-600M (int8) | ~2GB | 0.6초 |
| 2 | 신속 | large-v3-turbo (int8) | NLLB-600M (fp16) | ~4GB | 0.9초 |
| 3 | **균형** (기본) | large-v3-turbo (fp16) | NLLB-1.3B (fp16) | ~6GB | 1.2초 |
| 4 | **정밀** (추천) | large-v3-turbo (fp16) | Seed-X-PPO-7B (Int4) | ~10GB | 2.0초 |
| 5 | 극한 | large-v3 (fp16) | Qwen3-8B (fp16) + LoRA | ~16GB | 3.0초 |
## 결론: 무엇을 고를 것인가
**추가학습을 안 한다면 → 4티어 "정밀"이 최선이다.**
Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비정상적으로 좋다.
게임 대사처럼 짧고 구어체인 문장에서 NLLB 계열과 체감 차이가 크다.
**추가학습을 전제로 하면 → 5티어 "극한"(Qwen3-8B + LoRA)으로 간다.**
이유는 세 가지다.
1. Seed-X는 이미 PPO(강화학습)로 조율이 끝난 모델이다. 그 위에 다시 SFT를 얹으면
기존 품질이 깨지기 쉽다. 반면 Qwen3-8B는 instruct 베이스라 추가 학습을 전제로 만들어졌다.
2. peft / unsloth / TRL 등 LoRA 도구가 Qwen 계열에 가장 잘 맞춰져 있다.
3. 용어집을 프롬프트로 직접 지시할 수 있어 "학습 + 프롬프트" 이중으로 용어를 잡을 수 있다.
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고).
## Seed-X를 쓸 때 반드시 지켜야 하는 것
Seed-X는 일반 챗 모델이 아니라 **번역만 하도록 학습된 completion 모델**입니다.
모델 카드가 명시하는 제약이 세 가지 있고, 구현에 그대로 반영했습니다.
1. **프롬프트 끝의 언어 태그는 필수입니다.** PPO 학습에 쓰인 신호라 빠지면 품질이
크게 흔들립니다. 형식은 정확히 이렇습니다.
```
Translate the following English sentence into Korean:
May the force be with you <ko>
```
2. **chat template이 없습니다.** `apply_chat_template`을 쓰거나 멀티턴 대화 형식으로
넣으면 안 됩니다.
3. **지시문을 끼워 넣으면 안 됩니다.** "구어체로 자연스럽게 옮겨라" 같은 문장을 붙이면
학습 분포를 벗어납니다. 그래서 4티어는 용어집도 프롬프트가 아니라
플레이스홀더 치환으로 넣습니다.
이 제약 때문에 프롬프트 빌더를 `build_seedx_prompt()`로 분리하고, 형식이 바뀌면
바로 깨지도록 `tests/test_prompts.py`에 회귀 테스트를 걸어놨습니다.
참고로 `AWQ-Int4`/`GPTQ-Int8`은 ByteDance가 직접 배포하는 **공식** 양자화본입니다
(모델 카드가 경고하는 것은 제3자 양자화본입니다). 다만 AWQ 가중치를 읽으려면
`pip install autoawq`가 필요합니다.
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의
체감 품질은 다를 수 있다.** 특히 Seed-X의 우위는 자체 발표 수치에 크게 기대고 있다.
그래서 프로그램에 5개 티어를 모두 넣었다. 실제 쓰는 콘텐츠로 3·4·5티어를 직접
번갈아 써보고 정하는 것이 가장 정확하다.
## 참고
- [Best open source STT model in 2026 (benchmarks)](https://northflank.com/blog/best-open-source-speech-to-text-stt-model-in-2026-benchmarks)
- [Whisper Large-v3 vs Turbo: Speed, WER & Cost](https://vexascribe.com/whisper-large-v3-vs-turbo)
- [Seed-X: Building Strong Multilingual Translation LLM with 7B Parameters (arXiv)](https://arxiv.org/html/2507.13618v1)
- [ByteDance-Seed/Seed-X-PPO-7B (Hugging Face)](https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B)
- [Local translation benchmark 2026 (cctrans)](https://github.com/kargnas/cctrans/blob/main/docs/local-translation-benchmark-2026.md)
- [CTranslate2 지원 모델](https://opennmt.net/CTranslate2/guides/transformers.html)

587
docs/WINDOWS-TESTING.md Normal file
View File

@@ -0,0 +1,587 @@
# Windows에서 자동 검증 붙이기
개발은 리눅스에서 하고 있어서 **Windows 전용 경로는 테스트가 불가능**합니다.
오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기에 해당합니다.
그래서 "리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있습니다.
Windows 러너를 하나 붙이면 `main` 에 push할 때(또는 Actions 탭에서 수동
실행할 때) 이 경로들이 자동으로 검증됩니다.
## 어디에 올릴까 — 서버 Windows VM 권장
개인 PC를 안 쓰고 서버에 올려 완전 자동화하는 게 가장 깔끔합니다.
**GPU가 없어도 됩니다.** 실제로 확인해봤습니다.
| 검증 항목 | GPU 필요? | GPU 없는 서버 VM |
|---|---|---|
| 단위 테스트 189개 | ❌ | ✅ |
| WASAPI 장치·프로그램 열거 | ❌ | ✅ |
| 전역 단축키 | ❌ | ✅ |
| 모니터 열거·자막 배치 | ❌ | ✅ |
| 포터블 exe 빌드·기동 | ❌ | ✅ |
| **오디오 → 한국어 자막 (전 과정)** | ❌ CPU로 됨 | ✅ |
| CUDA 인식·VRAM 상한 | ✅ | ⚠️ 경고만 (빌드 통과) |
### GPU 검증은 `.9`(리눅스)에서 — 이미 돌아갑니다
GPU는 `.9`에 고정입니다. 그래서 GPU 검증은 Windows가 아니라 **여기서** 합니다.
한 줄이면 됩니다.
```bash
scripts/gpu-check.sh
```
RTX 5050 실측 결과입니다.
```
[OK] CUDA 사용 가능 RTX 5050 · 7.5GB
[OK] VRAM 상한이 실제로 걸리는지 35% 상한 적용됨
[OK] GPU 로 실제 번역 '적 왼쪽에서 오는'
[OK] 티어 게이트가 뭐라고 하는지 정밀=autoawq 없음 / 극한=VRAM 부족(16GB 필요, 7.5GB)
6/6 통과
```
**이 GPU로는 4·5티어를 쓸 수 없습니다.** 정밀은 `autoawq`가 필요하고
(`pip install autoawq`), 극한은 16GB가 필요한데 카드가 7.5GB입니다.
극한은 카드를 바꾸지 않는 한 영영 불가능합니다. 1~3티어는 정상 동작합니다.
### GPU가 필요한 테스트는 어떻게 하나
**GPU는 한 VM만 독점합니다.** 컨슈머 NVIDIA는 vGPU/SR-IOV를 지원하지 않아서
쪼개 쓸 방법이 없습니다. 지금 RTX 5050은 `.9`(리눅스)에 물려 있습니다.
그래서 러너를 **라벨로 둘로 나눠뒀습니다.** 지금은 앞의 것만 있으면 되고,
나중에 GPU 머신이 생기면 뒤의 것을 붙이기만 하면 됩니다. 워크플로는 이미
그렇게 짜여 있습니다.
| 러너 라벨 | 어디에 | 언제 도나 | 무엇을 |
|---|---|---|---|
| `windows:host` | GPU 없는 서버 VM | `main` push마다 자동 | 테스트·Windows 경로·오디오→자막·exe 빌드 |
| `windows-gpu:host` | GPU 달린 머신 | **수동 실행만** | CUDA 인식·VRAM 상한·4·5티어·GPU 번역 |
GPU 러너를 상시로 띄울 필요는 없습니다. 필요할 때만 `daemon`을 켜고
Actions 탭에서 `Windows GPU` → `Run workflow` 하면 됩니다. 러너가 없으면
큐에 쌓이기만 하고 아무 일도 일어나지 않습니다.
GPU 워크플로는 GPU가 없으면 **분명히 실패합니다.** GPU를 확인하겠다고 부른
작업이 조용히 통과하면 안 되니까요. `nvidia-smi`가 없으면 그 자리에서 멈춥니다.
#### 여러 프로젝트가 GPU 러너를 같이 쓸 때 — 동시에 돌리면 안 됩니다
**이게 가장 중요합니다.** GPU는 나눠 쓸 수 없어서, 두 작업이 동시에 VRAM을
잡으면 둘 다 OOM으로 죽거나 하나가 조용히 느려져 원인을 못 찾습니다.
CPU 러너와 달리 GPU 러너는 **반드시 한 번에 한 작업만** 돌아야 합니다.
보장 장치가 두 겹입니다.
1. **러너 `capacity: 1`** — 진짜 보장입니다. 저장소가 달라도 막힙니다.
`act_runner` 설정 파일(`config.yaml`)에서 확인하세요. 기본값이 1이지만
GPU 러너에서는 반드시 확인하고 넘어가세요.
```yaml
runner:
capacity: 1 # GPU 러너는 절대 올리지 말 것
```
설정 파일이 없으면 만들고 `--config config.yaml` 로 띄웁니다.
```powershell
.\act_runner.exe generate-config > config.yaml
.\act_runner.exe daemon --config config.yaml
```
2. **워크플로 `concurrency`** — 같은 저장소 안에서 겹치는 것만 막습니다.
`windows-gpu.yml` 에 이미 들어 있습니다. 다른 저장소에는 효력이 없으니
1번을 대신하지 못합니다.
한 대에 러너 프로세스를 **여러 개 띄우지도 마세요.** capacity 1짜리 러너가
둘이면 결국 동시에 두 작업이 돕니다.
#### GPU 머신을 어디서 구하나
다른 프로젝트도 이 러너에서 GPU 테스트를 돌리실 거라면 셋 중 하나입니다.
1. **서버에 GPU 한 장 더** — 가장 깔끔합니다. PCIe 슬롯과 파워 여유만 있으면
그 GPU를 Windows VM에 패스스루하고 `windows-gpu` 라벨로 등록하면 끝입니다.
이후로는 두 러너가 각자 계속 돕니다.
2. **RTX 5050을 Windows VM으로 옮기기** — `.9`가 GPU를 포기해야 합니다.
`.9`에서 하던 GPU 작업(이미지 생성 등)을 못 하게 되니 득실을 따져보세요.
3. **필요할 때만 실물 PC에 GPU 러너** — 평소엔 꺼두고 GPU 검증이 필요할 때만
켭니다. 수동 실행이라 게임 중에 갑자기 도는 일은 없습니다.
1번이 가능한지는 서버에서 이걸로 확인할 수 있습니다.
```bash
lspci | grep -i vga # GPU가 몇 장 꽂혀 있나
dmidecode -t slot | grep -i "in use" # 빈 PCIe 슬롯이 있나
```
### 선택지 A — `.9` 안에 Windows VM (중첩 가상화)
Proxmox를 건드리지 않고 `.9`(우분투) 안에서 바로 Windows VM을 돌릴 수 있습니다.
**실제로 확인했습니다.**
```
/dev/kvm 존재
kvm_intel nested Y
qemu -enable-kvm kvm support: enabled ← 실측
여유 RAM / 디스크 25GB / 294GB
```
**가장 큰 장점은 사람 손이 안 간다는 것입니다.** `.9` 안이라 에이전트가 VM
생성부터 러너 등록까지 직접 합니다. Proxmox 자격증명도 필요 없습니다.
**단, GPU는 여전히 안 됩니다.** 중첩 패스스루가 불가능합니다.
```
IOMMU 그룹 수 0개
커널 cmdline iommu 옵션 없음
```
되게 하려면 Proxmox가 `.9`에 vIOMMU를 열어주고 `.9` 커널에 `intel_iommu=on`을
넣은 뒤 GPU를 `vfio-pci`에 묶어야 하는데, 그러면 **`.9`가 GPU를 잃습니다.**
결국 "GPU를 누가 가질 것인가" 문제로 되돌아오므로 중첩으로는 해결되지 않습니다.
#### 만드는 법 — 스크립트 한 줄, 사람 손 없음
`scripts/win-ci/` 에 전부 들어 있습니다. Windows 설치 화면을 사람이 클릭할
방법이 없으므로 **무인 설치**로 짰습니다.
```bash
scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스크 생성
/home/claude/win-ci/start-vm.sh # VM + 제어 서버 기동
/home/claude/win-ci/boot-cd.sh reset # CD 부팅 프롬프트 넘기기 (최초 1회)
/home/claude/win-ci/status.sh # 진행 상황
/home/claude/win-ci/shot.sh a.png # 화면 스냅샷
```
네 부분이 이어달리기를 합니다.
| 파일 | 어디서 도나 | 하는 일 |
|---|---|---|
| `unattend/autounattend.xml` | Windows Setup | 디스크 분할·설치·OOBE 건너뛰기·`ci` 자동 로그온 |
| `unattend/setup/provision.ps1` | 첫 로그온 1회 | virtio 드라이버(네트워크·화면), 화면꺼짐/잠금 해제 |
| `unattend/setup/agent.ps1` | 로그온마다 상주 | 호스트에서 `stage2.ps1` 을 받아 실행 |
| `serve/stage2.ps1` | 호스트가 HTTP 로 제공 | Git·pwsh7·Python·Node·CMake·act_runner 설치 |
**왜 설치 내용을 ISO 에 안 넣었나** — ISO 는 한 번 구우면 못 고칩니다. 설치
스크립트를 ISO 에 박으면 한 줄 고칠 때마다 Windows 를 다시 깔아야 합니다.
그래서 ISO 에는 껍데기(agent)만 넣고, 실제 설치 내용은 호스트의 HTTP 로
가져옵니다. `serve/stage2.ps1` 을 고치면 게스트가 20초 안에 알아서 새로
받아 실행합니다.
호스트와 게스트를 잇는 통로는 두 개입니다.
| 방향 | 통로 | 왜 |
|---|---|---|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST `/log` → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
| 게스트 → 호스트 | HTTP POST `/artifact/<이름>` → `results/<이름>.log` | 검증 출력 **전문**. 바로 아래 참고 |
##### 검증 결과는 `results/` 에 파일로 남습니다 — 그게 기준선입니다
`stage2.ps1` 의 `Step` 이 각 단계 출력을 게스트 `C:\win-ci\out\` 에 한 줄씩
기록하면서 호스트 `/home/claude/win-ci/results/` 로 올립니다. 판정 요약은
`results/summary.log` 이고 **검증한 리비전이 박혀** 있습니다.
처음에는 로그 채널로 마지막 몇 줄만 흘렸는데, `serial.log` 는 VM 이 뜰 때마다
truncate 되고 `guest.log` 는 `stage2` 가 다시 돌 때만 채워집니다. 그래서 pytest
실패 트레이스백이 호스트에 한 줄도 남지 않아, 화면을 읽고 옮겨 적는 수밖에
없었습니다. 고친 뒤에 고쳐졌다고 말할 근거가 없는 상태였습니다.
재검증은 리비전으로 요청합니다.
```bash
cd scripts/win-ci && ./sync.sh <sha> # stage2 의 $TargetRev 를 그 커밋으로 박아 배포
tail -f /home/claude/win-ci/guest.log # 약 10분, 끝나면 results/summary.log 확인
```
게스트 agent 는 `stage2.ps1` **내용 해시가 바뀔 때만** 다시 돕니다. 커밋만
푸시하면 재검증이 걸리지 않으니 sha 를 넘기세요. `build.sh` 를 다시 돌리면
안 됩니다 — 지워둔 Windows ISO 4.8GB 를 다시 내려받습니다.
배포할 포터블 exe 도 같은 통로로 돌아옵니다. 빌드와 기동이 **둘 다** 통과했을
때만 `results/LiveSub.exe` 로 올라오므로, 거기 파일이 있다는 것 자체가
"검증을 통과한 산출물" 이라는 뜻입니다. 그대로 릴리스에 올리면 됩니다.
##### exe 빌드 단계가 거짓 PASS 를 내던 함정
앞 회차의 `exe기동` 점검이 띄운 `LiveSub.exe` 가 파일을 잡고 있으면 PyInstaller
가 `PermissionError` 로 죽습니다. 그런데 **옛 exe 는 그대로 남아 있어서**
`Test-Path dist\LiveSub.exe` 검사는 통과했고, 결국 빌드가 실패한 회차가 PASS 로
보고됐습니다. 지금은 빌드 전에 프로세스를 죽이고 `build`·`dist` 를 지운 뒤
PyInstaller 종료코드까지 확인합니다. **산출물의 존재를 성공의 근거로 쓰면
안 됩니다** — 이전 성공이 남긴 것일 수 있습니다.
##### MSVC 가 왜 VM 에 깔려 있나
`native\process_loopback` 의 `livesub_capture.exe` 는 WASAPI 프로세스 루프백을
씁니다. 그 헤더(`audioclientactivationparams.h`)와 WRL 은 Windows SDK + MSVC
에만 있어서 cmake 만으로는 못 만듭니다. 그래서 `stage2.ps1` 이 VS 2022 Build
Tools(C++ 워크로드)를 설치합니다 — 첫 설치에 약 9분 걸리고 그 뒤로는 건너뜁니다.
이게 없으면 **포터블 빌드는 그대로 성공하되** 프로그램별 캡처가 출력 장치
전체 캡처로 조용히 폴백됩니다. 기능이 빠진 exe 가 PASS 로 나오는 조합이라,
검증 순서에서 `native_capture` 를 `windows_smoke` 와 `pyinstaller` **앞에**
둡니다. 뒤에 두면 둘 다 "없음"을 정상으로 보고 지나갑니다.
실제로 이 경로는 MSVC 를 깔기 전까지 **한 번도 컴파일된 적이 없었고**, 깔자마자
`main.cpp` 가 C2275 로 죽었습니다. CI 의 네이티브 빌드 단계가
`continue-on-error` 였던 탓에 그때까지 드러나지 않았습니다.
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
재부팅해도 살아납니다. `~/.config/systemd/user/win-ci{,-http}.service` 로
등록돼 있고 `linger=yes` 라 로그인 없이 `default.target` 이 끌어올립니다.
`systemd-run` 트랜지언트로 띄우면 `/run` 에만 남아 재부팅에 사라지니,
디버깅으로 한 번 띄웠더라도 마지막에는 반드시 아래로 되돌려 놓으세요.
```bash
systemctl --user start default.target # 부팅과 같은 경로로 기동
systemctl --user is-enabled win-ci.service # enabled 여야 한다. transient 면 안 된다
systemctl --user show win-ci.service -p FragmentPath # ~/.config/... 를 가리켜야 한다
```
##### 설치가 끝난 뒤 무엇을 지우나 — 기준
지우는 기준은 "VM 이 도는 데 계속 필요한가" 하나입니다.
| 파일 | 설치 후 | 왜 |
|---|---|---|
| `iso/win11-ltsc.iso` (4.8GB) | **지운다** | 설치 미디어. 다시 깔 때만 필요하고 `build.sh` 가 받아온다 |
| `iso/virtio-win.iso` (837MB) | 남긴다 | VM 에 계속 물려 있다. 드라이버 재설치·재프로비저닝이 여기서 읽는다 |
| `unattend.iso` (0.4MB) | 남긴다 | VM 에 계속 물려 있다 |
| `win-ci.qcow2` (14GB) | 남긴다 | VM 그 자체 |
ISO 를 지우면 `-drive` 가 없는 파일을 가리켜 **qemu 가 기동 자체에 실패**합니다.
재부팅 후 러너 호스트가 조용히 사라지므로, `run-vm.sh` 는 cdrom 을 있을 때만
붙입니다. 그리고 같은 ISO 를 버전별로 여러 개 남겨두지 마세요. 어느 것이
물려 있는지 헷갈리면 고친 줄 알았던 스크립트가 반영되지 않습니다. 현재
물려 있는 것은 `query-block` 으로 확인합니다.
##### 무인 설치에서 실제로 걸린 것들
- **`Press any key to boot from CD`** — UEFI 는 이걸 5초 안에 안 누르면 CD 를
포기합니다. 무인인데 누를 사람이 없어서 QMP `send-key` 로 밀어 넣습니다.
단, **설치가 시작된 뒤에는 절대 키를 더 보내면 안 됩니다.** 남는 Enter 가
설치 화면의 `Cancel` 을 눌러 "Are you sure you want to quit?" 를 띄웁니다.
반대로 설치가 끝난 뒤 재부팅 때는 아무 키도 안 누르는 게 정답입니다.
그래야 CD 를 건너뛰고 디스크로 부팅합니다.
- **시스템 디스크는 SATA(AHCI)** 로 둡니다. virtio-blk/scsi 로 하면 Windows
Setup 이 디스크 자체를 못 봐서 WinPE 단계 드라이버 주입이 필요해집니다.
네트워크·화면만 virtio 로 가고, 그 드라이버는 설치 후 ISO 에서 오프라인으로
넣습니다.
- **Windows ISO 는 UDF** 입니다. ISO9660 층에는 `README.TXT` 하나뿐이라
`xorriso` 로 열면 비어 보입니다. `mount -t udf` 로 봐야 합니다.
- `install.wim` 의 **인덱스 1** 이 `Windows 11 Enterprise LTSC 2024 Evaluation`
입니다(이미지 2개). `wiminfo` 로 확인하고 박았습니다.
- **LTSC 에는 winget 이 없습니다.** 스토어가 빠져 있어서 설치 파일을 직접
받습니다.
- **Node.js 가 필요합니다.** `act_runner` 의 host 모드는 `actions/checkout`,
`upload-artifact` 같은 JS 액션을 PATH 의 `node` 로 돌립니다. 빠뜨리면 그
단계들만 조용히 실패합니다.
- **러너는 서비스가 아니라 로그온 예약 작업**이어야 합니다. 서비스로 돌리면
데스크톱 세션이 없어 GUI 테스트와 스크린샷이 전부 실패합니다.
#### VM 에 얼마를 줄까 — 실측 근거
**vCPU 4개 / RAM 8GB / 디스크 80GB.** 추측이 아니라 재보고 정한 값입니다.
CPU 는 4개에서 포화합니다. 더 줘도 빨라지지 않습니다.
```
파이프라인(음성→자막) 2코어 13초 · 4코어 10초 · 8코어 11초
테스트 190개 4코어 5.9초 · 8코어 5.8초
```
CI 작업 대부분이 단일 스레드라 코어를 늘려도 효과가 없습니다. 음성인식만
병렬인데 그것도 4개에서 멈춥니다.
RAM 8GB 는 Windows 11 이 쾌적하게 도는 최소선이고, 그 안에서 도는
음성인식+번역이 2~3GB 를 씁니다. 디스크 80GB 는 Windows 30GB + 모델 캐시
+ 빌드 산출물 기준입니다.
#### VM 의 Windows 는 진짜 Windows 인가
**맞습니다. 같은 Windows 이고 같은 바이너리입니다.** 우리가 검증하려는 것들은
코드 경로가 동일합니다.
| | VM 에서 | 비고 |
|---|---|---|
| WASAPI 오디오 캡처 API | 동일 | 에뮬레이트된 사운드 장치를 잡을 뿐 API 는 같음 |
| 전역 단축키(`RegisterHotKey`) | 동일 | |
| 모니터 열거 API | 동일 | `max_outputs=2` 로 화면 2개를 줄 수 있음 |
| PyInstaller exe 빌드·실행 | 동일 | |
| CPU 명령어 집합 | 동일 | 중첩 KVM 이 호스트 CPU 기능을 그대로 노출 |
다른 점은 셋입니다.
- **GPU 없음** — CUDA·VRAM 검증은 `.9`(리눅스)에서 합니다. 위 참고.
- **실제 게임 소리 없음** — 게임을 띄워 진짜 캡처하는 건 못 합니다.
대신 음성 파일을 넣어 자막까지 나오는지 자동 검증합니다.
- **속도/지연이 다름** — 체감 지연 측정값은 실제 PC 기준이 아닙니다.
모니터 선택을 검증하려면 화면을 2개 줘야 합니다. `--vga std` 는 1개뿐입니다.
```
-device virtio-vga,max_outputs=2 # KVM 기동 확인 완료
```
Windows 쪽에 virtio-gpu 드라이버(virtio-win ISO)가 깔려 있어야 2개로 잡힙니다.
#### 100% 부하에서도 괜찮은가 — 재봤습니다
**VM 4 vCPU 까지는 안전하고, `.9` 의 CPU 를 늘리면 오히려 해롭습니다.**
`.9` 안에서 VM 이 4코어를 100% 써도 영향은 작습니다.
```
부하 없음 VM 4코어 100%
테스트 5.9초 -> 6.1초 (+3%)
파이프라인 9.8초 -> 11.0초 (+12%)
```
문제는 호스트입니다. 물리 호스트는 i7-11700(8코어/**16스레드**)뿐인데
`.5` 에서 운영 컨테이너 19개가 돌고 있습니다. `.5` 의 응답 속도를 재봤습니다.
```
.9 가 한가할 때 616~628ms 기준
.9 가 4코어 사용 657~678ms +7% ← VM 이 100% 인 실제 상황
.9 가 8코어 전부 사용 1487~1491ms +140% ← 봇까지 동시에 바쁠 때
```
**그래서 `.9` 의 vCPU 는 늘리면 안 됩니다.** 늘릴수록 호스트 지분이 커져
`.5` 의 운영 서비스가 느려집니다. 지금 8개가 적정 상한입니다.
최악(8코어 전부)을 아예 막으려면 VM 을 CPU 상한과 함께 띄웁니다.
```bash
systemd-run --user --scope -p CPUQuota=400% -p CPUWeight=50 --unit win-ci qemu-system-x86_64 -enable-kvm ...
```
`CPUWeight=50` 은 봇과 경합할 때 VM 이 양보한다는 뜻입니다.
`CPUQuota=200%` 로 재봤을 때 `.5` 응답이 649~653ms 로 기준선과 같았습니다.
RAM 은 최악을 다 더해도 여유가 있습니다.
```
ejclaw 봇 (실측 최대) 7.2 GB
Windows VM 게스트 8.0 GB
QEMU 오버헤드 0.4 GB
GPU 작업 동시 실행 3.0 GB
tmpfs 워치독 경보선 3.0 GB
─────────────────────────────────────
최악 합계 21.6 GB / 30 GB (여유 8.4 GB)
```
**결론: CPU·RAM 모두 증설 없이 진행하면 됩니다.** RAM 만 여유를 두고 싶다면
30 → 38GB 까지는 호스트가 감당합니다(호스트 62.6GB, 여유 약 24GB).
CPU 는 늘리지 마세요.
#### ⚠️ 메모리 cgroup 함정 — 반드시 `systemd-run --scope`
에이전트 턴에서 그냥 `qemu`를 띄우면 VM이 **봇의 cgroup 안에서** 돕니다.
`ejclaw.service`는 `MemoryHigh=16G`이고 평상시 5G를 쓰므로, 8G짜리 VM이
그 안에 들어가면 상한에 부딪혀 봇이 OOM으로 죽습니다(전례 있음).
```bash
# 봇 cgroup 밖(app.slice 형제)에 VM 을 띄운다 — 실측으로 확인됨
systemd-run --user --scope -p MemoryMax=10G --unit win-ci qemu-system-x86_64 -enable-kvm -m 8192 ...
```
#### A vs B 비교
| | A: `.9` 안 중첩 VM | B: Proxmox VM |
|---|---|---|
| 만드는 사람 | **에이전트가 직접** | 사용자 |
| GPU | ❌ | ❌ (별도 GPU 추가 시 ⭕) |
| 속도 | 중첩이라 다소 느림 | 네이티브 |
| `.9` 재부팅 시 | 같이 죽음 | 영향 없음 |
| RAM | `.9`의 30GB를 나눠 씀 | 독립 |
CI는 타임아웃이 30분인데 파이프라인이 27초라 속도 여유가 충분합니다.
**먼저 A로 띄워보고, 부족하면 B로 옮기는 편을 권합니다.**
### 선택지 B — Proxmox에 Windows VM 만들기
```bash
# Proxmox 호스트에서 (VMID 는 비어 있는 번호로)
qm create 920 --name win-ci --memory 8192 --cores 4 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-single --scsi0 local-lvm:80 --ostype win11 --machine q35 --bios ovmf --efidisk0 local-lvm:1,efitype=4m,pre-enrolled-keys=1 --tpmstate0 local-lvm:1,version=v2.0 --ide2 local:iso/Win11.iso,media=cdrom --ide0 local:iso/virtio-win.iso,media=cdrom --audio0 device=ich9-intel-hda,driver=none --vga std
```
RAM 8GB / 디스크 80GB면 충분합니다(모델 캐시 포함). 아래 둘을 꼭 넣으세요.
- `--audio0 ...` — **없으면 오디오 장치가 하나도 안 잡혀** WASAPI 점검이 실패합니다
- `--vga std` — 화면이 있어야 자막 오버레이를 그리고 스크린샷을 찍습니다
### VM 안에서 꼭 해야 하는 두 가지
**1. 자동 로그온** — 러너가 서비스로 돌면 데스크톱 세션이 없어 GUI 테스트와
스크린샷이 전부 실패합니다. 자동 로그온 + 로그온 시 실행이어야 합니다.
```powershell
# 관리자 PowerShell (VM 안이므로 부담 적음)
$k = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty $k AutoAdminLogon 1
Set-ItemProperty $k DefaultUserName "ci"
Set-ItemProperty $k DefaultPassword "<비밀번호>"
```
**2. 화면 꺼짐·잠금 끄기** — 잠기면 GUI 테스트가 실패합니다.
```powershell
powercfg /change monitor-timeout-ac 0
powercfg /change standby-timeout-ac 0
```
그 다음은 아래 일반 설치 절차와 같습니다. 러너는 **서비스가 아니라 로그온
예약 작업**으로 등록하세요(아래 "항상 켜두고 싶다면" 참고).
---
## 무엇이 자동으로 확인되나
| 항목 | 리눅스에서 | Windows 러너에서 |
|---|---|---|
| 단위 테스트 189개 | ✅ | ✅ |
| WASAPI 루프백 장치 열거 | ❌ | ✅ |
| 소리 내는 프로그램 열거 | ❌ | ✅ |
| 전역 단축키 등록/해제 | ❌ | ✅ |
| 모니터 열거·자막 배치 | 가상 화면만 | ✅ 실제 모니터 |
| 포터블 exe 빌드 | ❌ | ✅ |
| exe가 실제로 뜨는지 | ❌ | ✅ |
| GPU 인식 (CUDA) | 이 서버 GPU | ✅ 실제 게임 PC GPU |
| 실제 화면 스크린샷 | offscreen | ✅ 진짜 화면 |
결과물(exe, 스크린샷, 로그)은 Gitea Actions의 artifact로 올라오므로
원격에서도 눈으로 확인할 수 있습니다.
---
## 설치 (약 10분, 한 번만)
### 1. 필요한 것 설치
```powershell
winget install Git.Git # 소스 받기 (checkout) 에 필요
winget install Microsoft.PowerShell # 워크플로가 pwsh 7 을 씁니다
winget install Python.Python.3.12
winget install Kitware.CMake
winget install Microsoft.VisualStudio.2022.BuildTools # C++ 데스크톱 워크로드 선택
```
`Git.Git` 과 `Microsoft.PowerShell` 은 빠뜨리기 쉬운데 둘 다 필수입니다.
Git 이 없으면 소스를 못 받고, Windows 에 기본으로 깔린 PowerShell 은 5.1 이라
워크플로가 쓰는 `pwsh`(7.x) 가 없으면 단계마다 실패합니다.
설치 후 새 PowerShell 창을 열어 `git --version`, `pwsh --version` 이
나오는지 확인하세요.
### 2. Gitea 러너 내려받기
<https://gitea.com/gitea/act_runner/releases> 에서
`act_runner-<버전>-windows-amd64.exe` 를 받아 `C:\gitea-runner\act_runner.exe` 로 둡니다.
### 3. 등록 토큰 받기
Gitea 웹에서 저장소 → **설정 → Actions → 러너 → 등록 토큰 생성**
(주소: `https://git.tkrmagid.kr/tkrmagid/live-app-translator/settings/actions/runners`)
### 4. 등록하고 실행
```powershell
cd C:\gitea-runner
.\act_runner.exe register --no-interactive `
--instance https://git.tkrmagid.kr `
--token <위에서 받은 토큰> `
--name windows-test `
--labels windows:host
.\act_runner.exe daemon
```
`--labels windows:host` 가 중요합니다. 워크플로의 `runs-on: windows` 와 이름이
맞아야 하고, `:host` 는 컨테이너가 아니라 그 PC에서 직접 돌리라는 뜻입니다
(생략해도 기본값이 `host` 지만 명시하는 편이 낫습니다).
이름이 안 맞으면 Gitea 는 작업을 실패시키지 않고 **기본 우분투 컨테이너에서
돌립니다.** Windows 검증이 조용히 리눅스에서 돌아가는 셈이라, 워크플로 첫
단계에서 그걸 막아 뒀습니다.
### 5. 확인
워크플로는 **`main` 에 push될 때** 또는 **Actions 탭에서 수동 실행**할 때 돕니다.
(`.gitea/workflows/windows.yml` 의 `on:` 항목)
저장소 **Actions** 탭 → `Windows` → `Run workflow` 로 바로 한 번 돌려보세요.
---
## 항상 켜두고 싶다면 (선택)
**서비스(`sc.exe create`)로 등록하지 마세요.** 그렇게 하면 SYSTEM 권한으로
돌아가 러너가 PC 전체를 건드릴 수 있게 됩니다. 로그인할 때 **그 계정 권한으로**
뜨게 하는 편이 안전합니다.
```powershell
# 관리자 아님 — 그냥 평소 PowerShell
$action = New-ScheduledTaskAction -Execute "C:\gitea-runner\act_runner.exe" `
-Argument "daemon" -WorkingDirectory "C:\gitea-runner"
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName "gitea-runner" -Action $action -Trigger $trigger
```
게임할 때 방해되면 그냥 꺼두고, 확인이 필요할 때만 `daemon` 을 켜도 됩니다.
러너가 꺼져 있으면 워크플로는 대기만 하고 아무 일도 하지 않습니다.
---
## 안전에 대해 — 정확히 어떤 권한인가
**솔직하게 말하면, 이건 "그 PC에서 명령을 실행할 수 있는 권한"입니다.**
러너는 저장소의 워크플로 파일을 실행합니다. 그런데 저(Claude)는 이 저장소의
`main`에 push할 수 있으므로, 워크플로 파일을 고쳐서 커밋하면 결과적으로 그
PC에서 원하는 명령을 돌릴 수 있습니다. "커밋된 파일만 실행하니 안전하다"는
말은 정확하지 않습니다.
실제로 보장되는 것은 이것뿐입니다.
- **숨길 수 없습니다.** 실행되는 모든 것은 `.gitea/workflows/` 안에 있고
git 기록에 남습니다. 몰래 뭘 했는지 나중에 전부 확인할 수 있습니다.
- **러너를 끄면 즉시 멈춥니다.** `daemon` 을 종료하면 그만입니다.
- 러너는 **자기 계정 권한**으로 돕니다. 관리자로 띄우지 마세요.
그래서 권하는 방식은 이렇습니다.
| 방법 | 위험 | 얻는 것 |
|---|---|---|
| **Windows VM** (Proxmox 등) | 거의 없음 | 빌드·단축키·모니터·UI·exe 실행 |
| **전용 Windows 계정** | 낮음 — 그 계정 파일만 노출 | 위 전부 + 실제 GPU |
| 평소 쓰는 계정 | 개인 파일·브라우저 세션 노출 | 위 전부 + 실제 게임 소리 |
**VM이나 전용 계정을 권합니다.** GPU와 실제 게임 소리 확인은 못 하지만,
지금 미검증인 항목의 대부분(빌드·단축키·모니터·UI·exe 기동)은 거기서도
그대로 검증됩니다. GPU/오디오는 필요할 때만 평소 계정에서 한 번씩 확인하는
편이 낫습니다.
부담스러우면 러너를 붙이지 않고 아래 "러너 없이 직접 돌려보기"만 하셔도
충분합니다.
---
## 러너 없이 직접 돌려보기
러너를 안 붙이고 그때그때 확인만 하고 싶다면 이것만 실행하고 결과를
보내주셔도 됩니다.
```powershell
git clone https://git.tkrmagid.kr/tkrmagid/live-app-translator
cd live-app-translator
py -3.12 -m venv .venv
.\.venv\Scripts\python -m pip install -r packaging\requirements-portable.txt pytest
$env:PYTHONPATH="src"
.\.venv\Scripts\python -m pytest tests -q
.\.venv\Scripts\python packaging\windows_smoke.py
```
`artifacts\windows-smoke.txt` 와 `artifacts\*.png` 를 보내주시면 됩니다.

BIN
docs/images/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/images/first-run.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/images/glossary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
docs/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/images/models.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
docs/images/overlay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
docs/images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
docs/images/subtitle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

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

View File

@@ -0,0 +1,21 @@
# 프로그램별 오디오 캡처 보조 프로그램 빌드 (Windows 전용)
#
# 필요: Visual Studio 2022 Build Tools (C++ 데스크톱 워크로드) + CMake
# winget install Microsoft.VisualStudio.2022.BuildTools
# winget install Kitware.CMake
#
# 사용: powershell -ExecutionPolicy Bypass -File native\process_loopback\build.ps1
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$build = Join-Path $here "build"
cmake -S $here -B $build -A x64
cmake --build $build --config Release
$out = Join-Path $here "..\..\src\livesub\resources\bin\livesub_capture.exe"
if (Test-Path $out) {
Write-Host "빌드 완료: $out" -ForegroundColor Green
} else {
Write-Error "빌드는 끝났지만 결과물을 찾지 못했습니다."
}

View File

@@ -0,0 +1,234 @@
// livesub_capture.exe — 특정 프로세스(와 그 자식 프로세스)의 렌더 오디오만
// 뽑아내 stdout 으로 흘려보내는 보조 프로그램.
//
// 출력 포맷: 16000Hz / mono / 32-bit float, 헤더 없는 생 PCM.
// 사용법: livesub_capture.exe --pid 1234 [--exclude]
// --exclude 를 주면 해당 프로세스 트리를 "제외한" 나머지 소리를 받는다.
//
// Windows 10 2004(build 19041) 이상에서 동작하는 WASAPI process loopback API를
// 사용한다. Microsoft 의 ApplicationLoopback 샘플이 기반이다.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <audioclient.h>
#include <audioclientactivationparams.h>
#include <mmdeviceapi.h>
#include <wrl/implements.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <io.h>
#include <vector>
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "mmdevapi.lib")
using Microsoft::WRL::ComPtr;
using Microsoft::WRL::RuntimeClass;
using Microsoft::WRL::RuntimeClassFlags;
using Microsoft::WRL::ClassicCom;
using Microsoft::WRL::FtmBase;
namespace {
constexpr int kSampleRate = 16000;
constexpr int kChannels = 1;
std::atomic<bool> g_stop{false};
BOOL WINAPI ConsoleHandler(DWORD type) {
if (type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT || type == CTRL_CLOSE_EVENT) {
g_stop = true;
return TRUE;
}
return FALSE;
}
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
//
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
// 같은 형태다.
class ActivationHandler
: public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
IActivateAudioInterfaceCompletionHandler> {
public:
explicit ActivationHandler(HANDLE done) : done_(done) {}
STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* op) override {
HRESULT activate_hr = S_OK;
ComPtr<IUnknown> unknown;
HRESULT hr = op->GetActivateResult(&activate_hr, &unknown);
if (SUCCEEDED(hr) && SUCCEEDED(activate_hr)) {
unknown.As(&client_);
}
result_ = FAILED(hr) ? hr : activate_hr;
SetEvent(done_);
return S_OK;
}
HRESULT result() const { return result_; }
ComPtr<IAudioClient> client() const { return client_; }
private:
HANDLE done_;
HRESULT result_ = E_FAIL;
ComPtr<IAudioClient> client_;
};
void Fail(const char* what, HRESULT hr) {
std::fprintf(stderr, "[livesub_capture] %s (hr=0x%08lX)\n", what, static_cast<unsigned long>(hr));
}
int Run(DWORD pid, bool exclude) {
HANDLE activated = CreateEventW(nullptr, FALSE, FALSE, nullptr);
HANDLE sample_ready = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!activated || !sample_ready) {
Fail("이벤트 생성 실패", HRESULT_FROM_WIN32(GetLastError()));
return 1;
}
AUDIOCLIENT_ACTIVATION_PARAMS params{};
params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;
params.ProcessLoopbackParams.TargetProcessId = pid;
params.ProcessLoopbackParams.ProcessLoopbackMode =
exclude ? PROCESS_LOOPBACK_MODE_EXCLUDE_TARGET_PROCESS_TREE
: PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;
PROPVARIANT activate_params{};
activate_params.vt = VT_BLOB;
activate_params.blob.cbSize = sizeof(params);
activate_params.blob.pBlobData = reinterpret_cast<BYTE*>(&params);
auto handler = Microsoft::WRL::Make<ActivationHandler>(activated);
ComPtr<IActivateAudioInterfaceAsyncOperation> async_op;
HRESULT hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK,
__uuidof(IAudioClient), &activate_params,
handler.Get(), &async_op);
if (FAILED(hr)) {
Fail("ActivateAudioInterfaceAsync 실패", hr);
return 2;
}
if (WaitForSingleObject(activated, 5000) != WAIT_OBJECT_0 || FAILED(handler->result())) {
Fail("오디오 인터페이스 활성화 실패 (Windows 10 2004 이상 필요)", handler->result());
return 3;
}
ComPtr<IAudioClient> client = handler->client();
if (!client) {
Fail("IAudioClient 를 받지 못했습니다", E_POINTER);
return 3;
}
// process loopback 경로에는 GetMixFormat 이 없으므로 포맷을 직접 지정한다.
WAVEFORMATEX format{};
format.wFormatTag = WAVE_FORMAT_IEEE_FLOAT;
format.nChannels = kChannels;
format.nSamplesPerSec = kSampleRate;
format.wBitsPerSample = 32;
format.nBlockAlign = format.nChannels * format.wBitsPerSample / 8;
format.nAvgBytesPerSec = format.nSamplesPerSec * format.nBlockAlign;
format.cbSize = 0;
constexpr REFERENCE_TIME kBufferDuration = 2 * 10'000'000LL; // 200ms
hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
kBufferDuration, 0, &format, nullptr);
if (FAILED(hr)) {
Fail("IAudioClient::Initialize 실패", hr);
return 4;
}
ComPtr<IAudioCaptureClient> capture;
hr = client->GetService(IID_PPV_ARGS(&capture));
if (FAILED(hr)) {
Fail("IAudioCaptureClient 획득 실패", hr);
return 4;
}
hr = client->SetEventHandle(sample_ready);
if (FAILED(hr)) {
Fail("SetEventHandle 실패", hr);
return 4;
}
hr = client->Start();
if (FAILED(hr)) {
Fail("스트림 시작 실패", hr);
return 4;
}
std::fprintf(stderr, "[livesub_capture] pid=%lu 캡처 시작 (%dHz mono f32)\n",
static_cast<unsigned long>(pid), kSampleRate);
std::fflush(stderr);
while (!g_stop) {
// 무음 구간에서는 이벤트가 오지 않으므로 타임아웃으로 빠져나와 종료 여부를 확인한다.
DWORD wait = WaitForSingleObject(sample_ready, 200);
if (wait == WAIT_TIMEOUT) continue;
if (wait != WAIT_OBJECT_0) break;
UINT32 packet = 0;
while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0) {
BYTE* data = nullptr;
UINT32 frames = 0;
DWORD flags = 0;
hr = capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr);
if (FAILED(hr)) break;
const size_t bytes = static_cast<size_t>(frames) * format.nBlockAlign;
if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
static thread_local std::vector<BYTE> zeros;
zeros.assign(bytes, 0);
std::fwrite(zeros.data(), 1, bytes, stdout);
} else if (data && bytes) {
std::fwrite(data, 1, bytes, stdout);
}
std::fflush(stdout);
capture->ReleaseBuffer(frames);
if (std::ferror(stdout)) { // 부모 프로세스가 파이프를 닫음
g_stop = true;
break;
}
}
}
client->Stop();
CloseHandle(activated);
CloseHandle(sample_ready);
return 0;
}
} // namespace
int main(int argc, char** argv) {
DWORD pid = 0;
bool exclude = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--pid") == 0 && i + 1 < argc) {
pid = static_cast<DWORD>(std::strtoul(argv[++i], nullptr, 10));
} else if (std::strcmp(argv[i], "--exclude") == 0) {
exclude = true;
}
}
if (pid == 0) {
std::fprintf(stderr, "사용법: livesub_capture.exe --pid <PID> [--exclude]\n");
return 64;
}
_setmode(_fileno(stdout), _O_BINARY);
SetConsoleCtrlHandler(ConsoleHandler, TRUE);
HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(hr)) {
Fail("CoInitializeEx 실패", hr);
return 1;
}
int rc = Run(pid, exclude);
CoUninitialize();
return rc;
}

View File

@@ -0,0 +1,51 @@
# 포터블 LiveSub.exe 빌드 (Windows 전용)
#
# powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
#
# 결과: dist\LiveSub.exe — 설치 없이 이 파일 하나만 있으면 실행됩니다.
# 모델은 exe 에 없고 첫 실행 때 %APPDATA%\LiveSub\models 로 내려받습니다.
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Push-Location $root
try {
$venv = Join-Path $root ".venv-portable"
if (-not (Test-Path $venv)) {
Write-Host "포터블 전용 가상환경을 만듭니다 (torch 없이)..." -ForegroundColor Cyan
py -3.12 -m venv $venv
}
$py = Join-Path $venv "Scripts\python.exe"
& $py -m pip install --upgrade pip --quiet
& $py -m pip install -r packaging\requirements-portable.txt
# torch 가 섞여 들어가면 exe 가 3GB 로 불어난다. 빌드 전에 확인한다.
& $py -c "import importlib.util,sys; sys.exit(1 if importlib.util.find_spec('torch') else 0)"
if ($LASTEXITCODE -ne 0) {
Write-Error "이 환경에 torch 가 있습니다. 포터블 빌드는 torch 없는 별도 venv 에서 하세요."
}
# 프로세스별 캡처 보조 프로그램이 있으면 같이 담긴다 (없어도 빌드는 된다).
$capture = Join-Path $root "src\livesub\resources\bin\livesub_capture.exe"
if (-not (Test-Path $capture)) {
Write-Warning "livesub_capture.exe 가 없습니다. 포터블은 '출력 장치 전체' 캡처만 됩니다."
Write-Warning "프로그램별 캡처를 넣으려면 native\process_loopback\build.ps1 을 먼저 실행하세요."
}
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
& $py -m PyInstaller packaging\livesub.spec --noconfirm
$out = Join-Path $root "dist\LiveSub.exe"
if (Test-Path $out) {
$mb = [math]::Round((Get-Item $out).Length / 1MB, 1)
Write-Host ""
Write-Host "빌드 완료: $out ($mb MB)" -ForegroundColor Green
Write-Host "이 파일 하나만 복사하면 됩니다. 설치 필요 없습니다." -ForegroundColor Green
} else {
Write-Error "빌드는 끝났지만 dist\LiveSub.exe 를 찾지 못했습니다."
}
}
finally {
Pop-Location
}

98
packaging/livesub.spec Normal file
View File

@@ -0,0 +1,98 @@
# -*- mode: python ; coding: utf-8 -*-
"""포터블 LiveSub 빌드 명세 (PyInstaller).
설치 없이 exe 하나만 두고 실행하는 것이 목표다. 다만 그냥 전부 넣으면
안 된다. torch+CUDA 만 2.5GB 라 --onefile 로 묶으면 **실행할 때마다**
그 2.5GB 를 임시폴더에 풀어야 해서 시작에 1분 넘게 걸린다.
그래서 포터블 빌드는 torch 를 뺀다. 음성인식(faster-whisper)과 번역
(NLLB) 모두 CTranslate2 위에서 돌아가고 CTranslate2 는 torch 를 요구하지
않기 때문에, 1~3티어는 그대로 다 동작한다.
다만 NLLB 토크나이저는 transformers 의 AutoTokenizer 를 쓰므로 transformers
자체는 넣어야 한다. transformers 는 torch 가 없으면 "토크나이저/설정만 사용
가능" 모드로 뜨며, 우리가 쓰는 용도가 정확히 그것이다 (실측 확인). 4~5티어(Seed-X / Qwen3)만
빠지며, 앱이 '모델' 화면에서 그 사실을 표시한다.
빌드:
pip install -r packaging/requirements-portable.txt pyinstaller
pyinstaller packaging/livesub.spec --noconfirm
결과:
dist/LiveSub.exe (약 400~600MB, 첫 실행 20~40초 / 이후 10초 내외)
모델은 exe 에 넣지 않는다. 처음 쓸 때 %APPDATA%\\LiveSub\\models 로
내려받으므로 exe 를 USB 로 옮겨도 모델은 그 PC 에 남는다.
"""
from pathlib import Path
from PyInstaller.utils.hooks import collect_dynamic_libs
BLOCK_CIPHER = None
ROOT = Path(SPECPATH).parent # noqa: F821 - PyInstaller 가 주입
SRC = ROOT / "src"
# 게임 용어집 JSON 과 (빌드돼 있다면) 프로세스 캡처 보조 프로그램을 함께 담는다.
datas = [(str(SRC / "livesub" / "resources" / "glossaries"), "livesub/resources/glossaries")]
capture_exe = SRC / "livesub" / "resources" / "bin" / "livesub_capture.exe"
if capture_exe.is_file():
datas.append((str(capture_exe), "livesub/resources/bin"))
# CTranslate2 는 CUDA/cuDNN DLL 을 곁에 두고 로드한다. 자동 수집에서 빠지기 쉽다.
binaries = collect_dynamic_libs("ctranslate2")
hiddenimports = [
"livesub.audio.process_loopback",
"livesub.audio.wasapi_loopback",
"ctranslate2",
"faster_whisper",
"tokenizers",
"sentencepiece",
"transformers",
"transformers.models.nllb",
"huggingface_hub",
]
# 넣으면 용량만 키우고 쓰지 않는 것들. torch 계열은 의도적으로 제외한다.
excludes = [
# transformers 는 제외하지 않는다. NLLB 토크나이저(AutoTokenizer)가 필요하고,
# torch 없이 토크나이저 전용으로 동작하는 것을 실제로 확인했다.
"torch", "torchvision", "torchaudio", "peft",
"accelerate", "datasets", "bitsandbytes", "autoawq",
"matplotlib", "scipy", "pandas", "IPython", "notebook",
"tkinter", "PySide6.QtWebEngineCore", "PySide6.Qt3DCore",
"PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtMultimedia",
"PySide6.QtQuick", "PySide6.QtQml", "PySide6.QtPdf", "PySide6.QtDesigner",
]
a = Analysis( # noqa: F821
[str(SRC / "livesub" / "__main__.py")],
pathex=[str(SRC)],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
# contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
runtime_hooks=[],
excludes=excludes,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=BLOCK_CIPHER) # noqa: F821
exe = EXE( # noqa: F821
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="LiveSub",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX 로 압축하면 백신이 오탐하는 일이 잦다
runtime_tmpdir=None,
console=False, # 콘솔 창 없이 GUI 로만
icon=str(ROOT / "src" / "livesub" / "resources" / "icon.ico"),
version=None,
)

View File

@@ -0,0 +1,25 @@
"""webrtcvad 훅 재정의 — contrib 훅이 배포 이름을 찾지 못해 빌드를 중단시킨다.
우리가 설치하는 건 `webrtcvad-wheels` 다. 원본 `webrtcvad` 는 Windows 용 휠을
올리지 않아서 MSVC 컴파일이 필요하기 때문이다. import 이름(`webrtcvad`)은 같지만
**배포 이름이 다르다.**
pyinstaller-hooks-contrib 의 `hook-webrtcvad.py` 는 `copy_metadata('webrtcvad')` 를
부른다. 배포 이름이 없으니 `PackageNotFoundError` 가 나고, PyInstaller 는 훅 모듈
import 실패를 `ImportErrorWhenRunningHook` 으로 올려 **빌드를 통째로 중단한다.**
Windows 포터블 빌드가 정확히 여기서 죽어 dist\\LiveSub.exe 가 안 나왔다.
hookspath 의 훅이 contrib 훅보다 먼저 검색되므로 같은 파일명으로 덮어쓴다.
메타데이터를 실제 배포 이름으로 수집하고, 둘 다 없으면 그냥 넘어간다 —
webrtcvad 런타임은 importlib.metadata 를 읽지 않으므로 없어도 동작한다.
"""
from PyInstaller.utils.hooks import copy_metadata
datas = []
for dist in ("webrtcvad-wheels", "webrtcvad"):
try:
datas = copy_metadata(dist)
break
except Exception: # noqa: BLE001 - 배포 이름이 없을 때만 다음 후보로 넘어간다
continue

View File

@@ -0,0 +1,27 @@
# 포터블 exe 전용 의존성 — torch 를 넣지 않는다.
#
# 음성인식(faster-whisper)도 번역(NLLB)도 CTranslate2 위에서 돌고
# CTranslate2 는 torch 를 요구하지 않는다. 덕분에 exe 가 3GB -> 500MB 가
# 된다. 대신 4~5티어(Seed-X / Qwen3)는 빠지며 앱이 그렇게 표시한다.
#
# 4~5티어까지 쓰려면 포터블이 아니라 일반 설치(requirements.txt)를 쓰세요.
PySide6>=6.6
numpy>=1.24
faster-whisper>=1.0
ctranslate2>=4.4
tokenizers>=0.19
sentencepiece>=0.2
# NLLB 토크나이저용. torch 없이 토크나이저 전용 모드로 동작한다.
transformers>=4.44
huggingface-hub>=0.24
# Windows 오디오 캡처
PyAudioWPatch>=0.2.12.7; sys_platform == "win32"
pycaw>=20240210; sys_platform == "win32"
comtypes>=1.4; sys_platform == "win32"
psutil>=5.9; sys_platform == "win32"
webrtcvad-wheels>=2.0.14
pyinstaller>=6.6

619
packaging/windows_smoke.py Normal file
View File

@@ -0,0 +1,619 @@
#!/usr/bin/env python3
"""Windows 전용 경로 점검.
리눅스 개발 환경에서는 확인이 불가능한 것들만 모아놨다. 여기서 실패하면
"리눅스에서 테스트 다 통과했는데 윈도우에서 안 되더라" 가 되는 지점이다.
python packaging/windows_smoke.py # 소스에서 점검
python packaging/windows_smoke.py --exe dist\\LiveSub.exe # 빌드된 exe 점검
결과는 artifacts/ 에 로그와 스크린샷으로 남는다. CI 가 그걸 업로드하므로
원격에서도 실제 화면을 눈으로 확인할 수 있다.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ARTIFACTS = ROOT / "artifacts"
sys.path.insert(0, str(ROOT / "src"))
# Qt 플랫폼 플러그인은 QApplication 을 만들기 *전에* 정해야 한다.
# 표시할 화면이 없는데 그냥 두면 플러그인 로드 실패로 SIGABRT 가 나면서
# 아무 결과도 못 남기고 죽는다. 점검기가 그렇게 죽으면 쓸모가 없다.
if sys.platform != "win32" and not (
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
_results: list[tuple[str, bool, str]] = []
def _qt_app():
"""QApplication 을 한 번만 만들어 재사용한다."""
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def check(name: str):
"""점검 하나. 예외가 나도 나머지는 계속 돈다."""
def wrapper(fn):
try:
detail = fn() or ""
ok = True
except Exception as exc: # noqa: BLE001 - 점검 결과로 기록하는 게 목적
detail, ok = f"{type(exc).__name__}: {exc}", False
_results.append((name, ok, str(detail)))
print(f" [{'OK ' if ok else '실패'}] {name} {detail}")
return fn
return wrapper
# --- 소스 기준 점검 ---------------------------------------------------------
def run_source_checks() -> None:
print(f"\n=== Windows 전용 경로 (실행 플랫폼: {sys.platform}) ===")
if sys.platform != "win32":
print(" 주의: Windows 가 아니라 Windows 전용 항목은 당연히 실패합니다.")
@check("WASAPI 루프백 장치 열거")
def _devices():
from livesub.audio.wasapi_loopback import WasapiLoopbackCapture
if not WasapiLoopbackCapture.available():
raise RuntimeError("PyAudioWPatch 없음 — 오디오 캡처 불가")
sources = WasapiLoopbackCapture.list_sources()
if not sources:
raise RuntimeError("루프백 장치가 하나도 없음")
return f"{len(sources)}개: {sources[0].label}"
@check("소리 내는 프로그램 열거 (pycaw)")
def _processes():
from livesub.audio.process_loopback import ProcessLoopbackCapture
sources = ProcessLoopbackCapture.list_sources()
return f"{len(sources)}개" + (f" (예: {sources[0].label})" if sources else " (지금 소리 내는 앱 없음)")
@check("프로그램별 캡처 보조 프로그램")
def _helper():
from livesub.audio.process_loopback import helper_path
path = helper_path()
if path is None:
raise RuntimeError("livesub_capture.exe 없음 — 장치 전체 캡처로 폴백됨")
return str(path)
@check("전역 단축키 등록/해제")
def _hotkey():
from livesub.ui.hotkeys import GlobalHotkeys
_qt_app()
keys = GlobalHotkeys()
if not keys.supported:
raise RuntimeError("이 플랫폼은 전역 단축키 미지원")
fired = []
if not keys.register("Ctrl+Alt+F12", lambda: fired.append(1)):
raise RuntimeError(keys.last_error)
keys.unregister_all()
return "Ctrl+Alt+F12 등록·해제 성공"
@check("모니터 열거")
def _screens():
from livesub.ui.placement import list_screens
_qt_app()
screens = list_screens()
if not screens:
raise RuntimeError("모니터를 찾지 못함")
return " / ".join(s.label for s in screens)
@check("GPU 인식")
def _gpu():
from livesub.models import detect_gpu
gpu = detect_gpu()
if not gpu.available:
raise RuntimeError(gpu.reason)
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
@check("사용 가능한 모델 티어")
def _tiers():
from livesub.models.manager import has_torch, tier_availability
from livesub.models.tiers import ordered_tiers
usable = [t.name for t in ordered_tiers() if tier_availability(t)[0]]
return f"torch={'있음' if has_torch() else '없음'} → {', '.join(usable)}"
@check("자막 오버레이 실제 렌더링")
def _overlay():
from livesub.config import AppConfig
from livesub.ui.main_window import MainWindow
app = _qt_app()
cfg = AppConfig()
cfg.models.preload_on_start = False
win = MainWindow(cfg)
try:
win.show()
win.overlay.set_subtitle_visible(True)
win.overlay.set_line("적이 왼쪽에서 들어온다, 지금 바로 빠져!", "Enemy from the left")
for _ in range(10):
app.processEvents()
time.sleep(0.05)
ARTIFACTS.mkdir(exist_ok=True)
win.grab().save(str(ARTIFACTS / "windows-main.png"))
win.overlay.grab().save(str(ARTIFACTS / "windows-overlay.png"))
finally:
win.engine.shutdown()
win.overlay.close()
win.close()
return "스크린샷 2장 저장 (artifacts/)"
@check("저장 위치 선택 화면")
def _data_location():
"""첫 실행 때 뜨는 위치 선택 화면이 실제 Windows 에서 제대로 그려지는지.
경로 표기(`C:\\...`)와 디스크 여유 공간 조회는 OS 마다 다르게 도는
부분이라 리눅스에서 통과해도 여기서 깨질 수 있다.
"""
from livesub.storage import data_dir, default_data_dir, free_space, human_size
from livesub.ui.data_location import DataLocationDialog
from livesub.ui.theme import stylesheet
app = _qt_app()
ARTIFACTS.mkdir(exist_ok=True)
shots = 0
for name, start, first in (
("windows-first-run.png", default_data_dir(), True),
("windows-change-location.png", data_dir(), False),
):
dialog = DataLocationDialog(start, first_run=first)
try:
dialog.setStyleSheet(stylesheet("dark"))
dialog.resize(560, dialog.sizeHint().height())
dialog.show()
app.processEvents()
if not dialog.ok_button.isEnabled():
raise RuntimeError(f"{start} 를 못 쓴다고 판정됨: {dialog.status.text()}")
dialog.grab().save(str(ARTIFACTS / name))
shots += 1
finally:
dialog.close()
dialog.deleteLater()
return f"{default_data_dir()} · 여유 {human_size(free_space(default_data_dir()))} · 스크린샷 {shots}장"
# --- 전 파이프라인 점검 -----------------------------------------------------
def run_pipeline_check(timeout_s: int = 900) -> None:
"""오디오 파일 하나를 넣어 한국어 자막이 나오는지 끝까지 본다.
개별 부품이 다 통과해도 이어 붙이면 안 되는 경우가 있다. 이 점검이
통과하면 "음성인식 -> 번역 -> 자막" 이 실제로 동작한다는 뜻이다.
GPU 없이 CPU 로 돌린다. 서버의 GPU 없는 Windows VM 에서도 그대로 된다.
모델은 처음 한 번만 받고 그 뒤로는 캐시를 쓴다.
"""
print(f"\n=== 전 파이프라인 (오디오 -> 자막), 플랫폼: {sys.platform} ===")
@check("오디오 파일 -> 한국어 자막")
def _pipeline():
from livesub.config import AppConfig
from livesub.core.engine import TranslationEngine
clip = ROOT / "tests" / "fixtures" / "en_callout_16k.wav"
if not clip.is_file():
raise RuntimeError(f"테스트 음성이 없습니다: {clip}")
cfg = AppConfig()
cfg.models.tier = "lightning" # CI 에서는 가장 가벼운 티어로 충분하다
cfg.models.device = "cpu" # GPU 없는 VM 에서도 돌아야 한다
cfg.models.preload_on_start = True
cfg.audio.backend = "file"
cfg.audio.target_process_name = str(clip)
cfg.audio.silence_ms = 400
cfg.performance.game_mode = False
lines: list = []
errors: list[str] = []
def on_status(status) -> None:
if status.state.value == "error":
errors.append(status.message)
engine = TranslationEngine(cfg, on_line=lines.append, on_status=on_status)
engine.start()
deadline = time.time() + timeout_s
try:
while time.time() < deadline and not errors:
if any(line.is_final for line in lines):
break
time.sleep(0.5)
finally:
engine.shutdown()
if errors:
raise RuntimeError(f"엔진 오류: {errors[0]}")
finals = [line for line in lines if line.is_final]
if not finals:
raise RuntimeError(f"{timeout_s}초 안에 자막이 나오지 않았습니다")
line = finals[0]
if "enemy" not in line.source_text.lower():
raise RuntimeError(f"음성인식이 빗나갔습니다: {line.source_text!r}")
if not any("\uac00" <= ch <= "\ud7a3" for ch in line.translated_text):
raise RuntimeError(f"한국어 자막이 아닙니다: {line.translated_text!r}")
ARTIFACTS.mkdir(exist_ok=True)
(ARTIFACTS / "pipeline.txt").write_text(
f"원문: {line.source_text}\n자막: {line.translated_text}\n",
encoding="utf-8",
)
return f"{line.source_text!r} -> {line.translated_text!r}"
# --- GPU 전용 점검 ----------------------------------------------------------
def run_gpu_checks() -> None:
"""GPU 가 있는 머신에서만 의미가 있는 것들.
GPU 는 한 VM 만 독점하므로(컨슈머 NVIDIA 는 vGPU 미지원) GPU 없는 CI VM
에서는 확인할 수 없다. 그래서 별도 러너 라벨(windows-gpu)로 떼어놨다.
여기서는 GPU 가 없으면 **분명히 실패**해야 한다 — GPU 를 확인하겠다고
부른 작업이 조용히 통과하면 안 된다.
"""
print(f"\n=== GPU 전용 (플랫폼: {sys.platform}) ===")
@check("CUDA 사용 가능")
def _cuda():
import torch
if not torch.cuda.is_available():
raise RuntimeError(
"torch 가 CUDA 를 못 씁니다. CPU 빌드가 깔렸는지 확인하세요 "
"(pip install torch --index-url https://download.pytorch.org/whl/cu128)"
)
idx = torch.cuda.current_device()
free, total = torch.cuda.mem_get_info(idx)
return f"{torch.cuda.get_device_name(idx)} · {total / 1024**3:.1f}GB (여유 {free / 1024**3:.1f}GB)"
@check("앱의 GPU 인식")
def _detect():
from livesub.models import detect_gpu
gpu = detect_gpu()
if not gpu.available:
raise RuntimeError(gpu.reason)
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
@check("VRAM 상한이 실제로 걸리는지")
def _vram():
from livesub.models.manager import apply_vram_limit
if not apply_vram_limit(0.35):
raise RuntimeError("상한 적용 실패 — 게임과 같이 쓸 때 VRAM 을 못 막습니다")
return "35% 상한 적용됨"
@check("티어 게이트가 뭐라고 하는지")
def _gate():
"""앱이 각 torch 티어를 쓸 수 있다고 하는지, 아니면 왜 막는지.
여기서는 판정하지 않고 그대로 보고만 한다. VRAM 이 작은 GPU 에서
4·5티어가 막히는 건 정상이라 실패로 처리하면 안 된다. 진짜 판정은
아래 "실제 로드" 가 한다 — 게이트가 된다고 한 걸 못 올리면 그게 버그다.
"""
from livesub.models.manager import detect_gpu, has_torch, tier_availability
from livesub.models.tiers import MTBackend, ordered_tiers
if not has_torch():
raise RuntimeError("torch 없음 — GPU 검증에는 일반 설치가 필요합니다")
gpu = detect_gpu()
parts = []
for t in ordered_tiers():
if t.mt.backend is not MTBackend.TRANSFORMERS:
continue
ok, reason = tier_availability(t, gpu)
parts.append(f"{t.name}={'가능' if ok else reason}")
return " / ".join(parts)
@check("게이트가 된다고 한 티어가 실제로 올라가는지")
def _real_load():
"""게이트 판정을 **실물로 검증**한다.
게이트가 "가능"이라 한 torch 티어를 실제로 올려 번역까지 시킨다.
여기서 죽으면 앱이 사용자에게 거짓말을 하고 있다는 뜻이다.
게이트가 전부 막았다면 검증할 대상이 없다. 그건 이 GPU 의 한계이지
회귀가 아니므로 실패로 처리하지 않고 이유를 남긴다.
"""
from livesub.models.glossary import Glossary
from livesub.models.manager import detect_gpu, tier_availability
from livesub.models.tiers import MTBackend, ordered_tiers
from livesub.models.translator import create_translator
gpu = detect_gpu()
allowed, blocked = [], []
for t in ordered_tiers():
if t.mt.backend is not MTBackend.TRANSFORMERS:
continue
ok, reason = tier_availability(t, gpu)
(allowed if ok else blocked).append(t if ok else f"{t.name}({reason})")
if not allowed:
return (
"검증 대상 없음 — 이 GPU 로는 torch 티어를 못 씁니다. "
f"{'; '.join(blocked)}"
)
tier = allowed[-1] # 열린 것 중 가장 무거운 것
translator = create_translator(tier.mt, device="cuda")
translator.load()
try:
out = translator.translate("Enemy coming from the left.", "en", "ko", Glossary())
finally:
translator.unload()
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
raise RuntimeError(f"{tier.name}: 한국어가 아닙니다 — {out!r}")
return f"{tier.name} 로드·번역 성공 {out!r}"
@check("GPU 로 실제 번역 (1~3티어 CUDA 경로)")
def _translate():
from livesub.models.glossary import Glossary
from livesub.models.packs import build_glossary
from livesub.models.tiers import get_tier
from livesub.models.translator import create_translator
tier = get_tier("swift") # CT2 지만 device=cuda 로 GPU 경로를 탄다
translator = create_translator(tier.mt, device="cuda")
translator.load()
glossary = build_glossary(Glossary(), ["fps-common", "pubg"])
try:
out = translator.translate("Enemy coming from the left.", "en", "ko", glossary)
finally:
translator.unload()
if not any("\uac00" <= ch <= "\ud7a3" for ch in out):
raise RuntimeError(f"한국어가 아닙니다: {out!r}")
ARTIFACTS.mkdir(exist_ok=True)
(ARTIFACTS / "gpu-translate.txt").write_text(out + "\n", encoding="utf-8")
return repr(out)
# --- 빌드된 exe 점검 --------------------------------------------------------
#: PyInstaller 가 진입 스크립트에서 예외가 났을 때 띄우는 창.
_CRASH_MARKERS = ("unhandled exception", "failed to execute script")
def _process_tree(pid: int) -> set[int]:
"""onefile exe 는 압축을 푼 뒤 자식 프로세스로 진짜 앱을 띄운다.
그래서 창의 주인은 우리가 Popen 한 pid 가 아니라 그 자식이다. 부모만 보면
창을 영영 못 찾는다.
"""
pids = {pid}
try:
import psutil
parent = psutil.Process(pid)
pids.update(child.pid for child in parent.children(recursive=True))
except Exception: # noqa: BLE001 - psutil 이 없어도 부모만으로 계속한다
pass
return pids
def _visible_windows(pids: set[int]) -> list[str]:
"""그 프로세스들이 띄운 보이는 최상위 창의 제목."""
import ctypes
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
titles: list[str] = []
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def _collect(hwnd, _lparam):
owner = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
if owner.value in pids and user32.IsWindowVisible(hwnd):
length = user32.GetWindowTextLengthW(hwnd)
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if buf.value:
titles.append(buf.value)
return True
user32.EnumWindows(_collect, 0)
return titles
def _terminate_tree(proc: subprocess.Popen) -> None:
"""부모만 죽이면 안 된다.
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고, 창과 로그 파일을 쥐고 있는
것은 자식이다. 부모만 terminate 하면 자식이 살아남아 로그 파일을 계속
잡고 있고, 임시 폴더를 지울 수 없어 점검이 엉뚱한 곳에서 실패한다.
"""
children = []
try:
import psutil
children = psutil.Process(proc.pid).children(recursive=True)
except Exception: # noqa: BLE001 - psutil 이 없으면 부모만이라도 정리한다
pass
for child in children:
with contextlib.suppress(Exception):
child.terminate()
proc.terminate()
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
for child in children:
with contextlib.suppress(Exception):
child.wait(timeout=10)
with contextlib.suppress(Exception):
if child.is_running():
child.kill()
def _launch_and_wait_for_window(
exe: Path, appdata: Path, require: str, timeout_s: int = 90
) -> str:
"""exe 를 띄우고 **기다리던 창이 뜰 때까지** 기다린다.
예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입
스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 —
프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가
릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다.
"아무 창이나 뜨면 통과" 도 부족하다. 자막 오버레이는 제목이 앱 이름
그대로라, 본 화면이 안 떠도 그게 잡혀 통과해 버린다. 그래서 어떤 창을
기다리는지(`require`)를 호출하는 쪽이 지정한다.
"""
env = dict(os.environ)
env["APPDATA"] = str(appdata)
proc = subprocess.Popen(
[str(exe)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
try:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if proc.poll() is not None:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
raise RuntimeError(f"창이 뜨기 전에 종료 (코드 {proc.returncode}): {err}")
titles = _visible_windows(_process_tree(proc.pid))
for title in titles:
if any(marker in title.lower() for marker in _CRASH_MARKERS):
raise RuntimeError(f"오류창이 떴습니다: {title!r}")
if any(require in title for title in titles):
return " / ".join(titles)
time.sleep(1)
raise RuntimeError(f"{timeout_s}초 안에 {require!r} 창이 뜨지 않았습니다")
finally:
_terminate_tree(proc)
@contextlib.contextmanager
def _scratch_appdata():
"""점검용 %APPDATA%.
치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가
파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로
튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다.
"""
tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-"))
try:
yield tmp
finally:
shutil.rmtree(tmp, ignore_errors=True)
def run_exe_check(exe: Path) -> None:
print(f"\n=== 빌드된 exe 점검: {exe} ===")
@check("exe 존재와 크기")
def _size():
if not exe.is_file():
raise RuntimeError("파일 없음")
mb = exe.stat().st_size / 1024 / 1024
if mb > 1500:
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
return f"{mb:.1f} MB"
@check("exe 첫 실행 — 저장 위치 화면")
def _first_run():
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
with _scratch_appdata() as tmp:
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
return f"창 {titles!r}"
@check("exe 두 번째 실행 — 본 화면")
def _second_run():
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
with _scratch_appdata() as tmp:
data = tmp / "data"
data.mkdir()
pointer = tmp / "LiveSub"
pointer.mkdir()
(pointer / "location.json").write_text(
json.dumps({"data_dir": str(data)}), encoding="utf-8"
)
# 모델 미리 올리기를 꺼둔다. 켜두면 창을 확인하러 온 점검이
# 수 GB 짜리 모델 내려받기를 시작했다가 중간에 죽인다.
(data / "config.json").write_text(
json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8"
)
# 자막 오버레이는 제목이 앱 이름뿐이라 그것만으로는 본 화면이 떴다는
# 증거가 못 된다. 본 화면 제목에만 있는 표어를 기다린다.
from livesub.constants import APP_SLOGAN
titles = _launch_and_wait_for_window(exe, appdata=tmp, require=APP_SLOGAN)
if any("저장 위치" in t for t in titles.split(" / ")):
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {titles!r}")
return f"창 {titles!r}"
def main() -> int:
parser = argparse.ArgumentParser(description="Windows 전용 점검")
parser.add_argument("--exe", type=Path, help="빌드된 exe 를 점검")
parser.add_argument(
"--pipeline", action="store_true",
help="오디오 파일로 전 파이프라인 점검 (모델을 내려받으므로 느립니다)",
)
parser.add_argument(
"--gpu", action="store_true",
help="GPU 가 있는 머신에서만 되는 항목 점검 (CUDA·VRAM 상한·4/5티어)",
)
args = parser.parse_args()
if args.exe:
run_exe_check(args.exe)
elif args.gpu:
run_gpu_checks()
elif args.pipeline:
run_pipeline_check()
else:
run_source_checks()
ARTIFACTS.mkdir(exist_ok=True)
report = "\n".join(
f"[{'OK' if ok else 'FAIL'}] {name}: {detail}" for name, ok, detail in _results
)
(ARTIFACTS / "windows-smoke.txt").write_text(report, encoding="utf-8")
failed = [n for n, ok, _ in _results if not ok]
print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과")
if failed:
print("실패:", ", ".join(failed))
# 오디오 장치·보조 프로그램·GPU 는 러너 환경에 따라 없을 수 있어 경고로만 둔다.
# 다만 --gpu 로 부른 경우엔 GPU 가 없는 것 자체가 실패다.
lenient = {
"프로그램별 캡처 보조 프로그램",
"소리 내는 프로그램 열거 (pycaw)",
"WASAPI 루프백 장치 열거",
"GPU 인식",
}
fatal = failed if args.gpu else [n for n in failed if n not in lenient]
return 1 if fatal else 0
if __name__ == "__main__":
raise SystemExit(main())

71
pyproject.toml Normal file
View File

@@ -0,0 +1,71 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "livesub"
version = "0.3.0"
description = "게임 소리를 실시간으로 번역해 화면 위 자막으로 보여주는 도구"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "tkrmagid" }]
dependencies = [
"PySide6>=6.6",
"numpy>=1.24",
]
[project.optional-dependencies]
# GPU 추론 (실사용에 필요). torch 는 CUDA 빌드를 따로 설치해야 한다 — README 참고.
gpu = [
"faster-whisper>=1.0",
"ctranslate2>=4.4",
"transformers>=4.44",
"huggingface-hub>=0.24",
"sentencepiece>=0.2",
]
# 4티어(Seed-X AWQ Int4) 전용 커널. 다른 티어에는 필요 없다.
awq = ["autoawq>=0.2.6"]
# Windows 오디오 캡처
windows = [
"PyAudioWPatch>=0.2.12.7",
"pycaw>=20240210",
"comtypes>=1.4",
"psutil>=5.9",
]
# 더 정확한 발화 구간 판정 (없어도 동작)
vad = ["webrtcvad-wheels>=2.0.14"]
# 추가학습 (LoRA)
finetune = [
"peft>=0.12",
"datasets>=2.20",
"accelerate>=0.33",
"bitsandbytes>=0.43; platform_system != 'Darwin'",
]
# webrtcvad 는 포터블 exe 의존성이라 CI 환경에는 항상 있다. 개발 venv 에만 없으면
# `_is_speech` 의 webrtcvad 경로가 로컬에서 통째로 안 돌고, 그 결과 같은 테스트가
# 로컬에서는 통과하고 Windows CI 에서만 깨진다 (실제로 그랬다). 환경을 맞춘다.
dev = ["pytest>=8.0", "ruff>=0.6", "webrtcvad-wheels>=2.0.14"]
[project.scripts]
livesub = "livesub.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
livesub = ["resources/bin/*", "resources/glossaries/*.json", "resources/icon.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
ignore = ["E501", "B008"]

25
requirements.txt Normal file
View File

@@ -0,0 +1,25 @@
# 기본 실행에 필요한 최소 구성
PySide6>=6.6
numpy>=1.24
# --- GPU 추론 ---------------------------------------------------------
# torch 는 반드시 CUDA 빌드로 먼저 설치하세요 (README 참고):
# pip install torch --index-url https://download.pytorch.org/whl/cu128
faster-whisper>=1.0
ctranslate2>=4.4
transformers>=4.44
huggingface-hub>=0.24
sentencepiece>=0.2
accelerate>=0.33
# --- Windows 오디오 캡처 ---------------------------------------------
PyAudioWPatch>=0.2.12.7; sys_platform == "win32"
pycaw>=20240210; sys_platform == "win32"
comtypes>=1.4; sys_platform == "win32"
psutil>=5.9; sys_platform == "win32"
# --- 선택 ------------------------------------------------------------
webrtcvad-wheels>=2.0.14
# 4티어(Seed-X AWQ Int4)를 쓸 때만 필요합니다:
# pip install autoawq

269
scripts/finetune_mt.py Normal file
View File

@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""번역 모델 추가학습 (게임·방송 용어).
사용법:
python scripts/finetune_mt.py --data data/game.jsonl --tier ultimate \
--output ./adapters/game-ko --epochs 3
데이터 형식 (JSONL, 한 줄에 한 쌍):
{"source": "...", "target": "...", "source_lang": "en", "target_lang": "ko"}
자세한 배경은 docs/FINETUNING.md 참고.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from livesub.constants import LANGUAGES, models_dir # noqa: E402
from livesub.models.tiers import ( # noqa: E402
MTBackend,
PromptStyle,
get_tier,
ordered_tiers,
)
from livesub.models.translator import build_seedx_prompt # noqa: E402
#: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름
LORA_TARGETS = ["q_proj", "v_proj"]
def load_pairs(path: Path) -> list[dict]:
pairs = []
with path.open(encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, 1):
raw = raw.strip()
if not raw:
continue
try:
item = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}:{lineno} JSON 오류: {exc}") from exc
for key in ("source", "target"):
if not item.get(key):
raise SystemExit(f"{path}:{lineno} '{key}' 가 비어 있습니다")
item.setdefault("source_lang", "en")
item.setdefault("target_lang", "ko")
for key in ("source_lang", "target_lang"):
if item[key] not in LANGUAGES:
raise SystemExit(f"{path}:{lineno} 지원하지 않는 언어: {item[key]}")
pairs.append(item)
if not pairs:
raise SystemExit(f"{path} 에 학습 데이터가 없습니다")
return pairs
def build_prompt(item: dict, style: PromptStyle = PromptStyle.INSTRUCT) -> tuple[str, str]:
"""LLM 학습용 (프롬프트, 정답) 쌍.
**추론 때 쓰는 프롬프트와 반드시 같아야 한다.** 형식이 어긋나면 학습은
정상적으로 끝나지만 실제 번역에서는 효과가 거의 나오지 않는다. 그래서
Seed-X 형식은 translator.py 의 함수를 그대로 재사용한다.
"""
if style is PromptStyle.SEEDX:
prompt = build_seedx_prompt(
item["source"], item["source_lang"], item["target_lang"]
)
return prompt, "\n" + item["target"]
src = LANGUAGES[item["source_lang"]]["english"]
tgt = LANGUAGES[item["target_lang"]]["english"]
prompt = (
f"Translate the following {src} text into {tgt}. "
"It is a live spoken line from a game or broadcast, so keep the tone "
"casual and natural. Output only the translation.\n\n"
f"{src}: {item['source']}\n{tgt}:"
)
return prompt, " " + item["target"]
def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int,
style: PromptStyle = PromptStyle.INSTRUCT):
import torch
from datasets import Dataset
from peft import LoraConfig, get_peft_model
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
DataCollatorForLanguageModeling,
Trainer,
TrainingArguments,
)
tokenizer = AutoTokenizer.from_pretrained(repo, cache_dir=str(models_dir()))
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
def encode(item):
prompt, answer = build_prompt(item, style)
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
answer_ids.append(tokenizer.eos_token_id)
ids = (prompt_ids + answer_ids)[:512]
# 프롬프트 부분은 손실에서 제외해야 '번역 결과'만 학습된다.
labels = ([-100] * len(prompt_ids) + answer_ids)[:512]
return {"input_ids": ids, "labels": labels, "attention_mask": [1] * len(ids)}
dataset = Dataset.from_list([encode(p) for p in pairs])
model = AutoModelForCausalLM.from_pretrained(
repo,
cache_dir=str(models_dir()),
torch_dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16,
device_map="auto",
)
model.enable_input_require_grads()
model = get_peft_model(
model,
LoraConfig(
r=rank,
lora_alpha=rank * 2,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=LORA_TARGETS,
),
)
model.print_trainable_parameters()
Trainer(
model=model,
args=TrainingArguments(
output_dir=str(output / "checkpoints"),
num_train_epochs=epochs,
per_device_train_batch_size=batch,
gradient_accumulation_steps=max(1, 8 // batch),
learning_rate=lr,
warmup_ratio=0.05,
logging_steps=20,
save_strategy="no",
gradient_checkpointing=True,
report_to=[],
),
train_dataset=dataset,
data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False),
).train()
model.save_pretrained(output)
tokenizer.save_pretrained(output)
def train_seq2seq(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int):
"""NLLB 계열. CTranslate2 변환 모델이 아니라 원본 HF 모델로 학습해야 한다."""
from datasets import Dataset
from peft import LoraConfig, get_peft_model
from transformers import (
AutoModelForSeq2SeqLM,
AutoTokenizer,
DataCollatorForSeq2Seq,
Seq2SeqTrainer,
Seq2SeqTrainingArguments,
)
tokenizer = AutoTokenizer.from_pretrained(repo, cache_dir=str(models_dir()))
def encode(item):
tokenizer.src_lang = LANGUAGES[item["source_lang"]]["nllb"]
tokenizer.tgt_lang = LANGUAGES[item["target_lang"]]["nllb"]
batch_enc = tokenizer(
item["source"], text_target=item["target"], truncation=True, max_length=256
)
return dict(batch_enc)
dataset = Dataset.from_list([encode(p) for p in pairs])
model = AutoModelForSeq2SeqLM.from_pretrained(repo, cache_dir=str(models_dir()))
model = get_peft_model(
model,
LoraConfig(
r=rank,
lora_alpha=rank * 2,
lora_dropout=0.05,
bias="none",
task_type="SEQ_2_SEQ_LM",
target_modules=LORA_TARGETS,
),
)
model.print_trainable_parameters()
Seq2SeqTrainer(
model=model,
args=Seq2SeqTrainingArguments(
output_dir=str(output / "checkpoints"),
num_train_epochs=epochs,
per_device_train_batch_size=batch,
learning_rate=lr,
warmup_ratio=0.05,
logging_steps=20,
save_strategy="no",
report_to=[],
),
train_dataset=dataset,
data_collator=DataCollatorForSeq2Seq(tokenizer, model=model),
).train()
model.save_pretrained(output)
tokenizer.save_pretrained(output)
def main() -> int:
parser = argparse.ArgumentParser(description="번역 모델 LoRA 추가학습")
parser.add_argument("--data", required=True, type=Path, help="JSONL 학습 데이터")
parser.add_argument(
"--tier",
default="ultimate",
choices=[t.key for t in ordered_tiers()],
help="어느 티어의 번역 모델을 학습할지 (기본: ultimate)",
)
parser.add_argument("--output", required=True, type=Path, help="어댑터 저장 폴더")
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--lr", type=float, default=2e-4)
parser.add_argument("--rank", type=int, default=16)
parser.add_argument("--batch", type=int, default=2)
parser.add_argument(
"--base-model",
default="",
help="티어 기본값 대신 쓸 HF 모델 (NLLB는 CT2 변환본이 아닌 원본을 지정해야 함)",
)
args = parser.parse_args()
tier = get_tier(args.tier)
pairs = load_pairs(args.data)
args.output.mkdir(parents=True, exist_ok=True)
if tier.mt.finetune_ease >= 3:
print(
f"경고: '{tier.name}' 티어의 번역 모델은 추가학습에 적합하지 않습니다.\n"
" docs/FINETUNING.md 참고 — 'ultimate' 티어를 권장합니다.\n",
file=sys.stderr,
)
if tier.mt.backend is MTBackend.TRANSFORMERS:
repo = args.base_model or tier.mt.repo
print(f"LLM LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
train_llm(
pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch,
tier.mt.prompt_style,
)
else:
# CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다.
repo = args.base_model or _hf_original(tier.mt.repo)
print(f"seq2seq LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
train_seq2seq(pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch)
print(f"\n완료. '모델' 화면의 추가학습 어댑터 칸에 입력하세요:\n {args.output.resolve()}")
return 0
def _hf_original(ct2_repo: str) -> str:
"""CTranslate2 변환 리포 이름에서 원본 facebook/ 리포를 추정한다."""
name = ct2_repo.split("/")[-1].replace("-ctranslate2", "")
return f"facebook/{name}"
if __name__ == "__main__":
raise SystemExit(main())

34
scripts/gpu-check.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# GPU 검증 — RTX 5050 이 달린 .9(리눅스)에서 돌린다.
#
# GPU 는 한 VM 만 독점하므로 Windows CI VM 에는 GPU 가 없다. 그래서 CUDA
# 인식·VRAM 상한·티어 게이트·실제 GPU 번역은 GPU 가 실제로 있는 여기서
# 확인한다. Windows 전용 경로(오디오·단축키·exe)는 Windows 러너가 맡는다.
#
# scripts/gpu-check.sh
#
# 종료코드 0 이면 전부 통과. 항목 하나라도 실패하면 1.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV="${GPU_VENV:-/home/claude/gpu-venv}"
if [[ ! -x "$VENV/bin/python" ]]; then
echo "GPU 용 파이썬이 없습니다: $VENV/bin/python" >&2
echo "GPU_VENV 로 경로를 지정하거나 torch(CUDA 빌드)가 깔린 venv 를 만드세요." >&2
exit 1
fi
# CTranslate2 가 libcublas.so.12 를 직접 찾는다. torch 가 딸려온 nvidia
# 패키지 안에 있으므로 경로를 잡아준다. 없으면
# "Library libcublas.so.12 is not found" 로 GPU 번역만 조용히 실패한다.
NV="$("$VENV/bin/python" -c 'import nvidia,os;print(os.path.dirname(nvidia.__file__))' 2>/dev/null || true)"
if [[ -n "$NV" ]]; then
for lib in cublas cudnn; do
[[ -d "$NV/$lib/lib" ]] && export LD_LIBRARY_PATH="$NV/$lib/lib:${LD_LIBRARY_PATH:-}"
done
fi
cd "$ROOT"
export PYTHONPATH="src"
exec "$VENV/bin/python" packaging/windows_smoke.py --gpu

44
scripts/win-ci/boot-cd.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# "Press any key to boot from CD or DVD" 를 넘긴다.
#
# UEFI 부팅 시 Windows ISO 는 매번 이 프롬프트를 띄우고 약 5초 안에 키가
# 없으면 CD 부팅을 포기한다. 무인 설치인데 사람이 눌러줄 수 없으므로
# 리셋 직후부터 키를 계속 밀어 넣는다.
#
# ⚠️ 설치가 시작된 뒤에는 절대 실행하지 말 것. 남는 Enter 가 설치 화면의
# Cancel 버튼을 눌러 "Are you sure you want to quit?" 가 뜬다 (실제로 겪음).
# 설치가 끝난 뒤 재부팅 때는 키를 안 누르는 게 정답이다 - 그래야 CD 를
# 건너뛰고 디스크로 부팅한다.
#
# 사용법: ./boot-cd.sh [reset] - reset 을 주면 VM 을 먼저 리셋한다
set -euo pipefail
MODE="${1:-}"
python3 - "$MODE" <<'PY'
import json, socket, sys, time
mode = sys.argv[1] if len(sys.argv) > 1 else ""
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock")
f = s.makefile("rwb")
f.readline()
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
line = f.readline()
if not line: raise SystemExit("qmp closed")
m = json.loads(line)
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
if mode == "reset":
print(cmd({"execute": "system_reset"}))
time.sleep(3)
# 프롬프트 창은 5초뿐이지만 펌웨어 POST 시간이 들쭉날쭉하다. 25초면 충분하고,
# 그 이상 밀면 설치 화면까지 Enter 가 새어 들어간다.
deadline = time.time() + 25
n = 0
while time.time() < deadline:
cmd({"execute": "send-key",
"arguments": {"keys": [{"type": "qcode", "data": "ret"}]}})
n += 1
time.sleep(0.4)
print("sent", n, "keys")
PY

64
scripts/win-ci/build.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# win-ci Windows VM 을 처음부터 만든다 (.9 안 중첩 KVM, 무인 설치).
#
# ./build.sh ISO 내려받기 + 무인설치 ISO 굽기 + 디스크 생성
# ./start-vm.sh VM + 제어 서버 기동 (systemd 트랜지언트 유닛)
# ./boot-cd.sh reset "Press any key to boot from CD" 넘기기 (최초 1회만)
# ./status.sh 진행 상황
# ./shot.sh out.png 화면 스냅샷
#
# 사람이 클릭할 일은 없다. autounattend.xml 이 설치를 끝내고, provision.ps1 이
# virtio 드라이버를 넣고, agent.ps1 이 호스트에서 stage2.ps1 을 받아 개발도구와
# act_runner 를 깐다.
#
# 필요 패키지: qemu-system-x86 ovmf swtpm swtpm-tools xorriso
# 그리고 실행 계정이 kvm 그룹에 있어야 한다 (없으면 sg kvm -c '...' 로 감싼다).
set -euo pipefail
D="${WIN_CI_DIR:-/home/claude/win-ci}"
HERE="$(cd "$(dirname "$0")" && pwd)"
WIN_ISO_URL='https://go.microsoft.com/fwlink/p/?linkid=2289029' # Win11 Ent LTSC 2024 평가판(90일, 키 불필요)
VIRTIO_URL='https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso'
mkdir -p "$D/iso" "$D/serve" "$D/swtpm" "$D/unattend-root/setup"
fetch() { # url dest 최소크기MB
if [ -f "$2" ] && [ "$(stat -c%s "$2")" -gt $(( $3 * 1024 * 1024 )) ]; then
echo "이미 있음: $2"; return
fi
echo "다운로드: $2"
curl -fL --retry 3 -o "$2" "$1"
}
fetch "$WIN_ISO_URL" "$D/iso/win11-ltsc.iso" 4000
fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500
# 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서
# autounattend.xml 을 찾으므로 CD 로 물려주면 된다.
cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/"
# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다.
# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로
# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고,
# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은
# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에
# 첫 설치의 provision.ps1 이 통째로 안 돌았다.
for f in "$HERE/unattend/setup/"*.ps1; do
out="$D/unattend-root/setup/$(basename "$f")"
printf '\xef\xbb\xbf' > "$out"
cat "$f" >> "$out"
done
xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/"
# 호스트 제어 채널이 내려줄 스크립트
cp "$HERE/serve/stage2.ps1" "$D/serve/"
cp "$HERE"/*.sh "$HERE"/serve-host.py "$D/"
chmod +x "$D"/*.sh
[ -f "$D/win-ci.qcow2" ] || qemu-img create -f qcow2 "$D/win-ci.qcow2" 80G
# SecureBoot 용 - MS 키가 미리 들어 있는 변수 이미지여야 Windows 부트로더가 통과한다
[ -f "$D/OVMF_VARS.fd" ] || cp /usr/share/OVMF/OVMF_VARS_4M.ms.fd "$D/OVMF_VARS.fd"
echo
echo "준비 끝. 다음: $D/start-vm.sh 그리고 $D/boot-cd.sh reset"

28
scripts/win-ci/click.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# VM 화면의 좌표를 클릭한다 (usb-tablet 절대좌표).
# 사용법: ./click.sh <x> <y> [화면폭 화면높이] 기본 1280x800
set -euo pipefail
python3 - "$@" <<'PY'
import json, socket, sys, time
x, y = int(sys.argv[1]), int(sys.argv[2])
W = int(sys.argv[3]) if len(sys.argv) > 3 else 1280
H = int(sys.argv[4]) if len(sys.argv) > 4 else 800
s = socket.socket(socket.AF_UNIX); s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
def cmd(o):
f.write((json.dumps(o)+"\n").encode()); f.flush()
while True:
m = json.loads(f.readline())
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
ax, ay = int(x / W * 32767), int(y / H * 32767)
ev = [{"type": "abs", "data": {"axis": "x", "value": ax}},
{"type": "abs", "data": {"axis": "y", "value": ay}}]
print(cmd({"execute": "input-send-event", "arguments": {"events": ev}}))
time.sleep(0.3)
print(cmd({"execute": "input-send-event", "arguments": {"events": [
{"type": "btn", "data": {"down": True, "button": "left"}}]}}))
time.sleep(0.15)
print(cmd({"execute": "input-send-event", "arguments": {"events": [
{"type": "btn", "data": {"down": False, "button": "left"}}]}}))
PY

68
scripts/win-ci/run-vm.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# win-ci Windows VM 기동 (.9 안 중첩 KVM)
#
# 이 스크립트를 직접 실행하지 말 것. start-vm.sh 가 systemd 트랜지언트 유닛으로
# 띄운다. 에이전트 턴에서 그냥 qemu 를 띄우면 ejclaw.service 의 cgroup 안에서
# 돌아 MemoryHigh=16G 상한을 쳐서 봇이 OOM 으로 죽는다 (2026-07-26 전례).
set -euo pipefail
D=/home/claude/win-ci
cd "$D"
# TPM 2.0 - Windows 11 요구사항
if [ ! -S "$D/swtpm/sock" ]; then
swtpm socket --tpmstate "dir=$D/swtpm" \
--ctrl "type=unixio,path=$D/swtpm/sock" --tpm2 -d
for i in $(seq 1 20); do [ -S "$D/swtpm/sock" ] && break; sleep 0.3; done
fi
rm -f "$D/qmp.sock"
# 설치가 끝나면 Windows ISO(4.8GB)는 필요 없어서 지운다. 그래도 유닛이 뜨도록
# 없는 ISO 는 그냥 건너뛴다. 없는 파일을 -drive 로 주면 qemu 가 기동에 실패해
# 재부팅 후 러너 호스트가 조용히 사라진다.
CDROMS=()
add_cd() { # 파일 버스 [bootindex]
[ -f "$1" ] || { echo "cdrom 건너뜀 (없음): $1" >&2; return; }
local id="cd$(basename "$1" | tr -cd 'a-z0-9')"
CDROMS+=( -drive "file=$1,if=none,id=$id,media=cdrom,readonly=on" )
if [ -n "${3:-}" ]; then
CDROMS+=( -device "ide-cd,drive=$id,bus=$2,bootindex=$3" )
else
CDROMS+=( -device "ide-cd,drive=$id,bus=$2" )
fi
}
add_cd "$D/iso/win11-ltsc.iso" ide.1 1
add_cd "$D/iso/virtio-win.iso" ide.2
add_cd "$D/unattend.iso" ide.3
exec qemu-system-x86_64 \
-name win-ci,process=win-ci \
-machine q35,smm=on,vmport=off \
-accel kvm \
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
-smp 4,sockets=1,cores=4,threads=1 \
-m 8192 \
-rtc base=localtime,driftfix=slew \
-global driver=cfi.pflash01,property=secure,value=on \
-global ICH9-LPC.disable_s3=1 \
-drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd \
-drive if=pflash,format=raw,unit=1,file="$D/OVMF_VARS.fd" \
-chardev "socket,id=chrtpm,path=$D/swtpm/sock" \
-tpmdev emulator,id=tpm0,chardev=chrtpm \
-device tpm-tis,tpmdev=tpm0 \
-device virtio-vga,max_outputs=2,xres=1920,yres=1080 \
-audiodev none,id=aud0 \
-device intel-hda \
-device hda-duplex,audiodev=aud0 \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:13389-:3389 \
-device virtio-net-pci,netdev=net0 \
-device qemu-xhci,id=xhci \
-device usb-kbd -device usb-tablet \
-drive "file=$D/win-ci.qcow2,if=none,id=sysdisk,format=qcow2,cache=writeback,discard=unmap,aio=threads" \
-device ide-hd,drive=sysdisk,bus=ide.0,bootindex=2 \
"${CDROMS[@]}" \
-serial "file:$D/serial.log" \
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
-vnc 127.0.0.1:19 \
-boot menu=off,strict=on

129
scripts/win-ci/serve-host.py Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""win-ci VM 과의 통신 채널.
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append
POST /artifact/<name> 게스트 명령 출력 원본 -> results/<name> 에 파일로 저장
/artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면
"고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고
로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은
반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다.
127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
외부에 열 필요가 없다.
"""
import http.server
import os
import posixpath
import socketserver
import sys
from datetime import datetime
ROOT = "/home/claude/win-ci/serve"
LOG = "/home/claude/win-ci/guest.log"
RESULTS = "/home/claude/win-ci/results"
PORT = 8099
# 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다.
# 32MB 로 두면 배포할 exe 가 413 으로 튕긴다.
MAX_ARTIFACT = 1024 * 1024 * 1024
CHUNK = 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=ROOT, **kw)
def guess_type(self, path):
# .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서
# ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의
# 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다.
if str(path).endswith(".ps1"):
return "text/plain; charset=utf-8"
return super().guess_type(path)
def do_GET(self):
if self.path.rstrip("/") == "/ping":
body = b"pong\n"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
super().do_GET()
def do_POST(self):
path = self.path.rstrip("/")
if path == "/log":
self._recv_log()
elif path.startswith("/artifact/"):
self._recv_artifact(path[len("/artifact/") :])
else:
self.send_error(404)
def _recv_log(self):
n = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(n).decode("utf-8", "replace").strip()
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {data}\n")
self.send_response(204)
self.end_headers()
def _recv_artifact(self, name):
# 게스트가 주는 이름을 그대로 경로에 쓰면 안 된다. 파일명만 취한다.
name = posixpath.basename(name.replace("\\", "/"))
if not name or name.startswith("."):
self.send_error(400, "bad artifact name")
return
n = int(self.headers.get("Content-Length") or 0)
if n > MAX_ARTIFACT:
self.send_error(413, "artifact too large")
return
os.makedirs(RESULTS, exist_ok=True)
# 통째로 read() 하지 않는다. exe 는 100MB 가 넘어서 메모리에 다 올리면
# 봇 cgroup 의 메모리 상한을 건드린다. 조각으로 받아 바로 디스크에 쓴다.
#
# 그리고 임시 이름으로 받아 끝난 뒤에 옮긴다. 전송 중에 끊기면 반쪽짜리
# 파일이 정상 결과물처럼 남아 그대로 릴리스에 올라갈 수 있다.
final = os.path.join(RESULTS, name)
tmp = final + ".part"
left = n
with open(tmp, "wb") as f:
while left > 0:
block = self.rfile.read(min(CHUNK, left))
if not block:
break
f.write(block)
left -= len(block)
if left:
os.remove(tmp)
self.send_error(400, "artifact truncated")
return
os.replace(tmp, final)
print(f"artifact {name} {n} bytes", flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, fmt, *args):
# 접근 로그는 stdout 으로만. journald 가 받는다.
sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
sys.stdout.flush()
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True)
os.makedirs(RESULTS, exist_ok=True)
with Server(("127.0.0.1", PORT), Handler) as httpd:
print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
httpd.serve_forever()

View File

@@ -0,0 +1,425 @@
# win-ci VM 설치 스크립트 (호스트가 HTTP 로 내려준다 - agent.ps1 이 실행)
#
# 이 파일은 호스트의 /home/claude/win-ci/serve/stage2.ps1 이다. 고치면 게스트가
# 20초 안에 알아서 새로 받아 실행한다. Windows 재설치 필요 없다.
#
# 규칙: 반드시 idempotent. 이미 깔린 건 건너뛴다. 중간에 죽어도 다시 돌 수 있다.
#
# LTSC 2024 에는 winget(스토어)이 없다. 그래서 설치 파일을 직접 받는다.
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# 자식 프로세스 stdout 을 UTF-8 로 해독한다.
#
# 이게 없으면 PowerShell 이 자식의 출력을 콘솔 코드페이지(LTSC en-US = 437)로
# 읽는다. python 쪽은 PYTHONUTF8=1 로 UTF-8 을 내보내므로 한글이 전부 깨져서
# 호스트로 회수한 증거 파일의 실패 메시지를 읽을 수 없다. 여기서 바꾸는 건
# '해독' 쪽이라 파일 저장 인코딩(StreamWriter 는 UTF-8 고정)과는 무관하다.
try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch { }
$Dl = 'C:\win-ci\dl'
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
# Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에
# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가
# "Cannot convert System.Object[] to System.String" 으로 죽는다.
# 실제로 이것 때문에 Git 설치가 실패했다.
function Log($m) { Write-Host "$m" }
function Get-File($url, $name) {
$out = Join-Path $Dl $name
if ((Test-Path $out) -and (Get-Item $out).Length -gt 1mb) { Log "캐시 사용 $name"; return $out }
for ($i = 1; $i -le 3; $i++) {
try {
Log "다운로드 ($i/3) $name"
Invoke-WebRequest -Uri $url -OutFile $out -UseBasicParsing -TimeoutSec 600
return $out
} catch {
Log "실패: $($_.Exception.Message)"
if ($i -eq 3) { throw }
Start-Sleep -Seconds 10
}
}
}
function Sync-Path {
$m = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$u = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = "$m;$u"
}
function Have($exe) { Sync-Path; return [bool](Get-Command $exe -ErrorAction SilentlyContinue) }
# ------------------------------------------------------------------ Git
# actions/checkout@v4 가 git 을 쓴다. 없으면 소스를 못 받는다.
if (Have 'git') {
Log "git 이미 있음: $(git --version)"
} else {
$f = Get-File 'https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/Git-2.55.0.5-64-bit.exe' 'git.exe'
Log "Git 설치 중"
Start-Process $f -ArgumentList '/VERYSILENT', '/NORESTART', '/NOCANCEL', '/SP-' -Wait
if (-not (Have 'git')) { throw 'git 설치 실패' }
Log "git 설치 완료: $(git --version)"
}
# ------------------------------------------------------------------ pwsh 7
# 워크플로가 shell: pwsh 를 쓴다. Windows 기본은 5.1 이라 별도로 필요하다.
if (Have 'pwsh') {
Log "pwsh 이미 있음: $(pwsh --version)"
} else {
$f = Get-File 'https://github.com/PowerShell/PowerShell/releases/download/v7.4.6/PowerShell-7.4.6-win-x64.msi' 'pwsh.msi'
Log "PowerShell 7 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_PATH=1' -Wait
if (-not (Have 'pwsh')) { throw 'pwsh 설치 실패' }
Log "pwsh 설치 완료: $(pwsh --version)"
}
# ------------------------------------------------------------------ Python 3.12
if (Have 'python') {
Log "python 이미 있음: $(python --version 2>&1)"
} else {
$f = Get-File 'https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe' 'python.exe'
Log "Python 3.12 설치 중"
Start-Process $f -ArgumentList '/quiet', 'InstallAllUsers=1', 'PrependPath=1', `
'Include_test=0', 'Include_launcher=1', 'AssociateFiles=0' -Wait
if (-not (Have 'python')) { throw 'python 설치 실패' }
Log "python 설치 완료: $(python --version 2>&1)"
}
# ------------------------------------------------------------------ Node
# act_runner 의 host 모드는 JS 액션(checkout, upload-artifact)을 돌리려고
# PATH 의 node 를 쓴다. 없으면 그 단계들이 전부 실패한다.
if (Have 'node') {
Log "node 이미 있음: $(node --version)"
} else {
$f = Get-File 'https://nodejs.org/dist/v22.20.0/node-v22.20.0-x64.msi' 'node.msi'
Log "Node.js 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart' -Wait
if (-not (Have 'node')) { throw 'node 설치 실패' }
Log "node 설치 완료: $(node --version)"
}
# ------------------------------------------------------------------ CMake
# native\process_loopback 빌드용. 워크플로에서 continue-on-error 지만 있으면 검증된다.
if (Have 'cmake') {
Log "cmake 이미 있음: $(cmake --version | Select-Object -First 1)"
} else {
$f = Get-File 'https://github.com/Kitware/CMake/releases/download/v3.31.6/cmake-3.31.6-windows-x86_64.msi' 'cmake.msi'
Log "CMake 설치 중"
Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$f`"", '/qn', '/norestart', 'ADD_CMAKE_TO_PATH=System' -Wait
Sync-Path
if (Have 'cmake') { Log "cmake 설치 완료: $(cmake --version | Select-Object -First 1)" }
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
}
# ------------------------------------------------------ VS 2022 Build Tools (C++)
# livesub_capture.exe (native\process_loopback) 는 WASAPI 프로세스 루프백을 쓴다.
# 그 헤더(audioclientactivationparams.h)와 WRL 은 Windows SDK + MSVC 에만 있다.
# cmake 만 깔면 "generator 는 있는데 컴파일러가 없다"로 configure 에서 죽는다.
#
# 왜 빠뜨리면 안 되나: 이 exe 가 없으면 포터블 빌드가 그대로 성공하되
# 프로그램별 캡처가 장치 전체 캡처로 조용히 폴백된다. 즉 "게임 소리만 받는다"는
# 핵심 기능이 빠진 채로 exe 가 나온다. 실제로 그 상태의 exe 가 한 번 나왔다.
$VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
function Get-MsvcRoot {
if (-not (Test-Path $VsWhere)) { return $null }
$p = & $VsWhere -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
-property installationPath 2>$null
if ($p) { return ($p | Select-Object -First 1) }
return $null
}
$msvc = Get-MsvcRoot
if ($msvc) {
Log "MSVC 이미 있음: $msvc"
} else {
$f = Get-File 'https://aka.ms/vs/17/release/vs_BuildTools.exe' 'vs_BuildTools.exe'
Log "VS 2022 Build Tools 설치 중 (C++ 워크로드, 수 GB - 20분 이상 걸린다)"
# --wait 없이 Start-Process -Wait 만 쓰면 부트스트래퍼가 먼저 반환해 버려
# 설치가 끝나기 전에 다음 단계로 넘어간다. 둘 다 있어야 한다.
$p = Start-Process $f -Wait -PassThru -ArgumentList `
'--quiet', '--wait', '--norestart', '--nocache', `
'--add', 'Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended'
# 3010 = 설치는 됐고 재부팅만 권고. 실패가 아니다.
Log "Build Tools 설치기 종료코드 $($p.ExitCode)"
$msvc = Get-MsvcRoot
if ($msvc) { Log "MSVC 설치 완료: $msvc" }
else { Log "WARN MSVC 설치 실패 - livesub_capture.exe 를 빌드할 수 없다" }
}
# ------------------------------------------------------------------ act_runner
$RunnerDir = 'C:\gitea-runner'
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
New-Item -ItemType Directory -Force -Path $RunnerDir | Out-Null
if (Test-Path $RunnerExe) {
Log "act_runner 이미 있음"
} else {
Log "act_runner 다운로드"
Invoke-WebRequest -Uri 'https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-windows-amd64.exe' `
-OutFile $RunnerExe -UseBasicParsing -TimeoutSec 300
Log "act_runner 받음: $((Get-Item $RunnerExe).Length) bytes"
}
# GPU 러너와 달리 이건 CPU 러너지만, 같은 원칙으로 capacity 1 로 고정한다.
# 이 VM 은 vCPU 4개뿐이라 두 작업이 동시에 돌면 둘 다 느려지고 타임아웃 위험이 생긴다.
$cfg = Join-Path $RunnerDir 'config.yaml'
if (-not (Test-Path $cfg)) {
@'
log:
level: info
runner:
capacity: 1
timeout: 60m
shutdown_timeout: 3m
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
container:
network: ""
privileged: false
force_pull: false
host:
workdir_parent: C:\gitea-runner\work
'@ | Set-Content -Path $cfg -Encoding ASCII
Log "config.yaml 생성 (capacity 1)"
}
# ------------------------------------------- 러너 등록 (토큰이 호스트에 있을 때만)
# 호스트의 serve/runner-token.txt 에 등록 토큰을 넣어주면 여기서 등록한다.
# 없으면 이 단계만 건너뛰고 나머지는 전부 완료로 본다.
$registered = Test-Path (Join-Path $RunnerDir '.runner')
if ($registered) {
Log "러너 이미 등록됨"
} else {
$tok = $null
try {
$tok = (Invoke-WebRequest -Uri 'http://10.0.2.2:8099/runner-token.txt' -UseBasicParsing -TimeoutSec 10).Content
if ($tok -is [byte[]]) { $tok = [Text.Encoding]::ASCII.GetString($tok) }
$tok = $tok.Trim()
} catch { $tok = $null }
if ([string]::IsNullOrWhiteSpace($tok)) {
Log "등록 토큰 없음 (serve/runner-token.txt) - 러너 등록은 건너뜀. 사전 설치는 전부 끝났다."
} else {
Log "러너 등록 시도 (토큰 길이 $($tok.Length))"
Push-Location $RunnerDir
$out = & $RunnerExe register --no-interactive `
--instance 'https://git.tkrmagid.kr' `
--token $tok `
--name 'win-ci' `
--labels 'windows:host' 2>&1 | Out-String
Pop-Location
Log "register -> $($out.Trim())"
if (-not (Test-Path (Join-Path $RunnerDir '.runner'))) { throw "러너 등록 실패" }
# 서비스로 만들지 않는다. SYSTEM 권한이 되고, 데스크톱 세션이 없어
# GUI 테스트/스크린샷이 전부 실패한다. 로그온 예약 작업이어야 한다.
& schtasks.exe /Create /TN 'gitea-runner' /SC ONLOGON /RL HIGHEST /F `
/TR "cmd.exe /c cd /d C:\gitea-runner && act_runner.exe daemon --config config.yaml" | Out-Null
& schtasks.exe /Run /TN 'gitea-runner' | Out-Null
Log "러너 등록 + 로그온 작업 등록 완료, daemon 시작"
}
}
# ------------------------------------------------------------------ 요약
Sync-Path
Log "=== 설치 요약 ==="
foreach ($c in 'git', 'pwsh', 'python', 'node', 'cmake') {
$v = try { (& $c --version 2>&1 | Select-Object -First 1) } catch { '없음' }
Log ("{0,-8} {1}" -f $c, $v)
}
Log "act_runner $(if (Test-Path $RunnerExe) { 'OK' } else { '없음' }) 등록=$(Test-Path (Join-Path $RunnerDir '.runner'))"
# 모니터 2개가 실제로 잡혔는지 - virtio-gpu 드라이버가 들어갔는지 확인하는 지표다.
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
$screens = [System.Windows.Forms.Screen]::AllScreens
Log "화면 $($screens.Count)개: $(($screens | ForEach-Object { $_.Bounds.Width.ToString() + 'x' + $_.Bounds.Height }) -join ', ')"
$gpu = Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }
Log "비디오 어댑터: $($gpu -join ', ')"
} catch {
Log "화면 열거 실패: $($_.Exception.Message)"
}
Log "=== stage2 완료 ==="
# ==================================================================
# Windows 전용 경로 실제 검증
#
# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다.
# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역
# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다.
#
# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ==================================================================
$Repo = 'C:\win-ci\live-app-translator'
$OutDir = 'C:\win-ci\out'
# 검증 대상 리비전. 'main' 이면 origin/main 최신.
# 호스트에서 `./sync.sh <sha>` 를 쓰면 이 한 줄이 그 sha 로 바뀐 사본이 배포되고,
# 내용이 바뀌므로 게스트 agent 가 해시 차이를 보고 20초 안에 다시 돈다.
# = "이 커밋을 검증해라" 라는 요청이 곧 재실행 트리거다.
$TargetRev = 'main'
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# 출력 원본을 호스트 파일로 회수한다.
#
# 왜 필요한가: 로그 채널로는 마지막 몇 줄만 흘렸고 serial.log 는 VM 이 뜰 때마다
# truncate 된다. 그래서 pytest 실패 트레이스백이 호스트에 한 줄도 안 남았고
# 화면에서 읽어 옮겨 적는 상태였다. 파일로 된 기준선이 없으면 고친 뒤에
# 고쳐졌다는 증명을 할 수 없다.
function Send-Artifact($name, $path) {
if (-not (Test-Path $path)) { return }
try {
$bytes = [IO.File]::ReadAllBytes($path)
# 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
# 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
# 타임아웃은 넉넉히. 로그는 수십 KB 지만 포터블 exe 는 100MB 를 넘고
# slirp(QEMU user networking) 는 그만큼 빠르지 않다.
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 1800; Body = $bytes; ContentType = 'application/octet-stream' }
Invoke-RestMethod @req | Out-Null
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
} catch {
Log " -> $name 업로드 실패: $($_.Exception.Message)"
}
}
function Step($name, $block) {
$log = Join-Path $OutDir "$name.log"
Remove-Item $log -Force -ErrorAction SilentlyContinue
Log "--- [$name] 시작"
# StreamWriter + AutoFlush 로 한 줄씩 즉시 디스크에 쓴다. 중간에 예외로
# 죽어도 거기까지의 출력이 남아야 한다 (Tee-Object 는 그 보장이 없다).
$sw = [IO.StreamWriter]::new($log, $false, [Text.UTF8Encoding]::new($false))
$sw.AutoFlush = $true
$tail = [System.Collections.Generic.Queue[string]]::new()
$ok = $true
try {
& $block 2>&1 | ForEach-Object {
$line = "$_"
$sw.WriteLine($line)
$tail.Enqueue($line)
if ($tail.Count -gt 25) { [void]$tail.Dequeue() }
}
} catch {
$ok = $false
$sw.WriteLine("[step 예외] $($_.Exception.Message)")
}
$sw.Close()
foreach ($l in $tail) { Log " $l" }
if ($ok) { Log "--- [$name] 통과" } else { Log "--- [$name] 실패" }
Send-Artifact "$name.log" $log
return $ok
}
$ErrorActionPreference = 'Continue'
try {
Sync-Path
if (-not (Test-Path $Repo)) {
Log "저장소 clone"
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
}
$ref = if ($TargetRev -eq 'main') { 'origin/main' } else { $TargetRev }
Log "저장소 갱신 -> $ref"
Push-Location $Repo
& git fetch --all -q
& git reset --hard $ref -q
Pop-Location
Push-Location $Repo
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
if (-not (Test-Path $py)) { & python -m venv .venv-ci }
& $py -m pip install --upgrade pip --quiet
& $py -m pip install -r packaging\requirements-portable.txt --quiet
& $py -m pip install pytest ruff --quiet
# 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를
# 실제로 검증한 셈이 된다.
& $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)"
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
$env:PYTHONPATH = 'src'
# Windows 콘솔 기본 인코딩(cp1252)에서는 한글 print 가 UnicodeEncodeError 로
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
$env:PYTHONIOENCODING = 'utf-8'
$env:PYTHONUTF8 = '1'
# 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
# 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
$r = [ordered]@{}
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 캡처 도우미를 먼저 만든다. windows_smoke 가 이 exe 의 존재를 점검하고,
# livesub.spec 도 있을 때만 포터블 exe 안에 담는다. 순서가 바뀌면 둘 다
# "없음"으로 통과해서 기능이 빠진 exe 가 나온다.
$r['캡처도우미'] = Step 'native_capture' {
& cmake -S native\process_loopback -B native\process_loopback\build -A x64
if ($LASTEXITCODE -ne 0) { throw "cmake configure exit $LASTEXITCODE" }
& cmake --build native\process_loopback\build --config Release
if ($LASTEXITCODE -ne 0) { throw "cmake build exit $LASTEXITCODE" }
$cap = 'src\livesub\resources\bin\livesub_capture.exe'
if (-not (Test-Path $cap)) { throw "$cap 없음" }
"livesub_capture.exe $((Get-Item $cap).Length) bytes"
}
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 점검이 찍은 스크린샷을 묶어서 호스트로 가져온다. 화면 관련 회귀는 로그로는
# 안 보이고, 문서에 넣을 그림도 실제 Windows 에서 찍은 것이어야 한다.
try {
$png = Join-Path $Repo 'artifacts'
if (Test-Path $png) {
$zip = Join-Path $OutDir 'screenshots.zip'
Remove-Item $zip -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $png '*.png') -DestinationPath $zip -Force
Send-Artifact 'screenshots.zip' $zip
}
} catch { Log "스크린샷 회수 실패: $($_.Exception.Message)" }
$r['exe빌드'] = Step 'pyinstaller' {
& $py -m pip install pyinstaller --quiet
# 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다.
# 1) 앞 회차의 exe기동 점검이 띄운 LiveSub 가 파일을 잡고 있어
# PyInstaller 가 EXE 를 덮어쓰지 못하고 PermissionError 로 죽는다.
# 2) 그런데 옛 exe 는 그대로 남아 있으니 Test-Path 는 통과한다.
# 즉 빌드가 실패했는데 PASS 로 보고된다 - 실제로 그렇게 오보가 났다.
Get-Process LiveSub -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 2
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
& $py -m PyInstaller packaging\livesub.spec --noconfirm
if ($LASTEXITCODE -ne 0) { throw "PyInstaller exit $LASTEXITCODE" }
if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }
"exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB"
}
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 빌드와 기동이 둘 다 통과했을 때만 배포용으로 회수한다. 실패한 exe 를
# 호스트로 올리면 그게 릴리스에 올라갈 위험이 있다.
if ($r['exe빌드'] -and $r['exe기동']) {
Send-Artifact 'LiveSub.exe' (Join-Path $Repo 'dist\LiveSub.exe')
} else {
Log "exe 회수 건너뜀 (빌드=$($r['exe빌드']) 기동=$($r['exe기동']))"
}
# 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
# 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
$rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
$sum = Join-Path $OutDir 'summary.log'
$lines = @("rev=$rev", "utc=$([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))")
Log "=== Windows 검증 결과 (rev $rev) ==="
foreach ($k in $r.Keys) {
$line = "{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })
Log $line
$lines += $line
}
Set-Content -Path $sum -Value $lines -Encoding UTF8
Send-Artifact 'summary.log' $sum
Log "=== 검증 끝 ==="
Pop-Location
} catch {
Log "검증 준비 단계에서 실패: $($_.Exception.Message)"
try { Pop-Location } catch {}
}

31
scripts/win-ci/shot.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# win-ci VM 화면 스냅샷. QMP screendump 로 찍는다.
# 사용법: ./shot.sh [출력경로.png]
set -euo pipefail
OUT="${1:-/home/claude/EJClaw/data/attachments/generated/win-ci-$(date +%H%M%S).png}"
mkdir -p "$(dirname "$OUT")"
python3 - "$OUT" <<'PY'
import json, socket, sys, time
out = sys.argv[1]
s = socket.socket(socket.AF_UNIX)
s.settimeout(20)
s.connect("/home/claude/win-ci/qmp.sock")
f = s.makefile("rwb")
f.readline() # greeting
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
line = f.readline()
if not line: raise SystemExit("qmp closed")
m = json.loads(line)
if "return" in m or "error" in m: return m
cmd({"execute": "qmp_capabilities"})
r = cmd({"execute": "screendump", "arguments": {"filename": out, "format": "png"}})
if "error" in r:
# 구버전 qemu 는 format 인자를 모른다 -> ppm 으로 받는다
r = cmd({"execute": "screendump", "arguments": {"filename": out + ".ppm"}})
print(json.dumps(r))
else:
print("ok", out)
PY
ls -l "$OUT"* 2>/dev/null

33
scripts/win-ci/start-vm.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# win-ci VM + 호스트 제어 서버를 systemd 트랜지언트 유닛으로 띄운다.
#
# 반드시 봇(ejclaw.service) cgroup 밖에서 돌아야 한다. 안에서 돌면 8GB VM 이
# MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다. --user 유닛으로 띄우면
# systemd --user 의 자식이 되어 app.slice 형제로 빠지고, 에이전트 턴이
# 끝나도 계속 살아 있는다.
#
# CPUQuota=400% / CPUWeight=50 - 물리 호스트가 i7-11700(16스레드)뿐이고 .5 에서
# 운영 컨테이너 19개가 돈다. 4코어를 넘기면 .5 응답이 +140% 느려진다(실측).
set -euo pipefail
systemctl --user reset-failed win-ci.service win-ci-http.service 2>/dev/null || true
# 게스트 <-> 호스트 제어 채널 (게스트는 10.0.2.2:8099 로 본다)
if ! systemctl --user is-active --quiet win-ci-http.service; then
systemd-run --user --unit=win-ci-http \
--description="win-ci guest control channel" \
-p Restart=always \
/usr/bin/python3 /home/claude/win-ci/serve-host.py
fi
# VM
systemd-run --user --unit=win-ci \
--description="win-ci Windows CI VM (nested KVM)" \
-p MemoryMax=10G \
-p CPUQuota=400% \
-p CPUWeight=50 \
-p WorkingDirectory=/home/claude/win-ci \
/bin/bash /home/claude/win-ci/run-vm.sh
sleep 2
systemctl --user --no-pager status win-ci-http.service win-ci.service | head -40

19
scripts/win-ci/status.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# win-ci VM 현재 상태를 한 번에 본다.
cd /home/claude/win-ci
echo "=== 유닛 ==="
systemctl --user is-active win-ci.service win-ci-http.service | paste -sd' ' -
systemctl --user show win-ci.service -p MemoryCurrent -p CPUUsageNSec --value | paste -sd' ' -
echo
echo "=== 디스크 사용량 (설치가 진행되면 커진다) ==="
du -h --apparent-size win-ci.qcow2 2>/dev/null | cut -f1
du -h win-ci.qcow2 | cut -f1
echo
echo "=== 게스트 로그 (HTTP, 최근 25줄) ==="
tail -n 25 guest.log 2>/dev/null || echo "(아직 없음 - 네트워크 올라오기 전)"
echo
echo "=== 시리얼 로그 (최근 25줄) ==="
tail -n 25 serial.log 2>/dev/null | tr -d '\r' || echo "(아직 없음)"
echo
echo "=== 러너 등록 토큰 ==="
if [ -s serve/runner-token.txt ]; then echo "있음 ($(wc -c < serve/runner-token.txt) bytes)"; else echo "없음 - serve/runner-token.txt 에 넣으면 게스트가 20초 안에 자동 등록"; fi

35
scripts/win-ci/sync.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
#
# ./sync.sh stage2.ps1 을 그대로 배포 (origin/main 최신을 검증)
# ./sync.sh <sha> 그 커밋을 검증하도록 $TargetRev 를 박아서 배포
#
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
#
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다. 반대로 내용이 같으면 다시 돌지
# 않는다 — 그래서 재검증을 요청할 때는 sha 를 넘겨 내용이 바뀌게 한다.
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
set -euo pipefail
D="${WIN_CI_DIR:-/home/claude/win-ci}"
HERE="$(cd "$(dirname "$0")" && pwd)"
REV="${1:-}"
mkdir -p "$D/serve" "$D/results"
cp "$HERE/serve/stage2.ps1" "$D/serve/"
if [ -n "$REV" ]; then
sed -i "s/^\$TargetRev = 'main'$/\$TargetRev = '$REV'/" "$D/serve/stage2.ps1"
grep -q "^\$TargetRev = '$REV'$" "$D/serve/stage2.ps1" || { echo "TargetRev 치환 실패"; exit 1; }
echo "검증 대상 리비전 = $REV"
fi
cp "$HERE"/*.sh "$HERE/serve-host.py" "$D/"
chmod +x "$D"/*.sh
if systemctl --user is-active --quiet win-ci-http.service; then
systemctl --user restart win-ci-http.service
echo "win-ci-http 재시작 (serve-host.py 반영)"
fi
echo "stage2.ps1 sha256 = $(sha256sum "$D/serve/stage2.ps1" | cut -c1-12) (게스트가 20초 안에 받아간다)"

67
scripts/win-ci/type.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# VM 게스트에 키보드 입력을 보낸다 (QMP send-key).
#
# ./type.sh text "hello world" 문자열 입력
# ./type.sh key ret 단일 키 (qcode)
# ./type.sh key alt-f4 조합키 (하이픈으로 연결)
#
# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것.
set -euo pipefail
python3 - "$@" <<'PY'
import json, socket, sys, time
MODE = sys.argv[1]
ARG = sys.argv[2]
s = socket.socket(socket.AF_UNIX); s.settimeout(30)
s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline()
def cmd(o):
f.write((json.dumps(o) + "\n").encode()); f.flush()
while True:
m = json.loads(f.readline())
if "return" in m or "error" in m:
if "error" in m: print("ERR", m, file=sys.stderr)
return m
cmd({"execute": "qmp_capabilities"})
def send(qcodes):
cmd({"execute": "send-key", "arguments": {
"keys": [{"type": "qcode", "data": q} for q in qcodes]}})
time.sleep(0.04)
SHIFTED = {
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
}
PLAIN = {
' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left',
']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash',
'`': 'grave_accent', '\n': 'ret', '\t': 'tab',
}
def char_keys(c):
if c.isalpha():
q = c.lower()
return ['shift', q] if c.isupper() else [q]
if c.isdigit():
return [c]
if c in SHIFTED:
return ['shift', SHIFTED[c]]
if c in PLAIN:
return [PLAIN[c]]
raise SystemExit("unsupported char: %r" % c)
if MODE == "text":
for c in ARG:
send(char_keys(c))
elif MODE == "key":
send(ARG.split("-"))
else:
raise SystemExit("usage: type.sh text <str> | type.sh key <qcode>")
print("ok")
PY

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>0409:00000409</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<!-- Win11 하드웨어 검사 우회. TPM/SecureBoot 는 실제로 제공하지만
중첩 가상화라 CPU/스토리지 검사가 튕길 수 있으므로 안전망으로 둔다. -->
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>5</Order>
<Path>reg add HKLM\SYSTEM\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
<DiskConfiguration>
<WillShowUI>OnError</WillShowUI>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>512</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>16</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Label>System</Label>
<Format>FAT32</Format>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Label>Windows</Label>
<Format>NTFS</Format>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>1</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<WillShowUI>OnError</WillShowUI>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>ci</FullName>
<Organization>livesub</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>WIN-CI</ComputerName>
<TimeZone>Korea Standard Time</TimeZone>
</component>
<!-- RDP 로 붙을 수 있게 열어둔다. 호스트에서 127.0.0.1:13389 로 포워딩된다. -->
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAuthentication>0</UserAuthentication>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Group>@FirewallAPI.dll,-28752</Group>
<Profile>all</Profile>
</FirewallGroup>
</FirewallGroups>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>ci</Name>
<DisplayName>ci</DisplayName>
<Group>Administrators</Group>
<Password>
<Value>LiveSubCI!2026</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Username>ci</Username>
<Enabled>true</Enabled>
<LogonCount>999</LogonCount>
<Password>
<Value>LiveSubCI!2026</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>provision windows ci runner</Description>
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>cmd.exe /c "for %d in (D E F G H I J K L M) do @if exist %d:\setup\provision.ps1 start /wait powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\setup\provision.ps1"</CommandLine>
</SynchronousCommand>
</FirstLogonCommands>
<TimeZone>Korea Standard Time</TimeZone>
</component>
</settings>
</unattend>

View File

@@ -0,0 +1,96 @@
# Windows CI VM 상주 에이전트
#
# 하는 일은 하나다: 호스트가 주는 stage2.ps1 을 받아서 돌린다.
#
# 왜 이렇게 하나 - ISO 는 한 번 구우면 못 고친다. 설치 스크립트를 ISO 에
# 박아두면 한 줄 고칠 때마다 Windows 를 다시 설치해야 한다. 그래서 ISO 에는
# 이 껍데기만 넣고, 실제 설치 내용은 호스트의 HTTP 로 가져온다.
# QEMU user networking 에서 호스트는 항상 10.0.2.2 다.
#
# 로그는 COM1(호스트 serial.log) + 호스트 HTTP(POST /log) + 로컬 파일.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Host_ = 'http://10.0.2.2:8099'
$WorkDir = 'C:\win-ci'
$Stage2 = Join-Path $WorkDir 'stage2.ps1'
$HashFile = Join-Path $WorkDir 'stage2.hash'
$LogFile = Join-Path $WorkDir 'agent.log'
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
function Say([string]$m) {
$line = "[agent $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {}
try {
$p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$p.Encoding = [Text.Encoding]::UTF8
$p.Open(); $p.WriteLine($line); $p.Close()
} catch {}
try {
Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 `
-Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null
} catch {}
}
# 같은 에이전트가 두 개 돌면 설치가 겹친다. 뮤텍스로 하나만 남긴다.
$mutex = New-Object System.Threading.Mutex($false, 'Global\win-ci-agent')
if (-not $mutex.WaitOne(0)) { Say "이미 다른 agent 가 돌고 있다. 종료."; exit 0 }
Say "agent 시작. user=$env:USERNAME"
# 네트워크가 올라오길 기다린다. virtio-net 드라이버 설치 직후라 몇 초 걸린다.
$netOk = $false
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "$Host_/ping" -TimeoutSec 4 -UseBasicParsing | Out-Null
$netOk = $true; break
} catch { Start-Sleep -Seconds 5 }
}
if ($netOk) { Say "호스트 연결 OK ($Host_)" }
else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드라이버를 확인해야 한다." }
# 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다.
while ($true) {
try {
# 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의
# .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해
# 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가
# 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다.
$wc = New-Object Net.WebClient
$body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1"))
# 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서
# Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고,
# PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로
# 인식되지 않는다. 그래서 여기서 반드시 떼어낸다.
$body = $body.TrimStart([char]0xFEFF)
$sha = [BitConverter]::ToString(
[Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body))
).Replace('-', '')
$prev = if (Test-Path $HashFile) { (Get-Content $HashFile -Raw).Trim() } else { '' }
if ($sha -ne $prev) {
Say "stage2 새 버전 감지 ($($sha.Substring(0,12))) - 실행"
Set-Content -Path $Stage2 -Value $body -Encoding UTF8
try {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Stage2 2>&1 |
ForEach-Object { Say "stage2| $_" }
if ($LASTEXITCODE -eq 0 -or $null -eq $LASTEXITCODE) {
# 성공했을 때만 해시를 저장한다. 중간에 죽었으면 다음 턴에 다시 돈다.
Set-Content -Path $HashFile -Value $sha
Say "stage2 성공"
} else {
Say "stage2 실패 exit=$LASTEXITCODE - 다음 폴링에 재시도"
}
} catch {
Say "stage2 예외: $($_.Exception.Message) - 다음 폴링에 재시도"
}
}
} catch {
Say "폴링 실패: $($_.Exception.Message)"
}
Start-Sleep -Seconds 20
}

View File

@@ -0,0 +1,110 @@
# Windows CI VM 최초 부팅 프로비저닝 (무인)
#
# 이 스크립트는 autounattend.xml 의 FirstLogonCommands 로 한 번만 실행된다.
# 네트워크가 아직 없는 상태에서 시작하므로, 순서가 중요하다.
# 1) virtio 드라이버 설치 (ISO 에서 오프라인) -> 이걸 해야 네트워크가 생긴다
# 2) 화면 꺼짐/잠금 끄기 (GUI 테스트가 잠금화면에서 실패하지 않게)
# 3) agent.ps1 을 C:\win-ci 에 심고 로그온 예약 작업으로 등록
# 4) agent 를 바로 실행 -> 이후 모든 설치는 호스트가 주는 stage2.ps1 이 한다
#
# 로그는 COM1(호스트의 serial.log)과 C:\win-ci\provision.log 양쪽에 남긴다.
# 네트워크가 없는 1) 단계를 호스트에서 관찰할 수 있어야 하기 때문이다.
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null
$script:Port = $null
try {
$script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One'
# 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다
$script:Port.Encoding = [Text.Encoding]::UTF8
$script:Port.Open()
} catch { $script:Port = $null }
function Say([string]$m) {
$line = "[provision $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"
try { Add-Content -Path 'C:\win-ci\provision.log' -Value $line -Encoding UTF8 } catch {}
if ($script:Port) { try { $script:Port.WriteLine($line) } catch {} }
}
Say "start. user=$env:USERNAME computer=$env:COMPUTERNAME"
# ---------------------------------------------------------------- 1) virtio
# virtio-win ISO 가 몇 번째 드라이브로 잡힐지 모른다. 전부 훑어 찾는다.
$virtio = $null
foreach ($d in [char[]](68..77)) { # D..M
$p = "${d}:\virtio-win-guest-tools.exe"
if (Test-Path $p) { $virtio = "${d}:"; break }
}
if (-not $virtio) {
Say "ERROR virtio-win ISO 를 못 찾음. 네트워크/화면 드라이버 없이 계속한다."
} else {
Say "virtio ISO = $virtio"
# 드라이버 스토어에 직접 넣는 쪽을 먼저 한다. 설치 프로그램보다 확실하다.
$infs = @(
"$virtio\NetKVM\w11\amd64\netkvm.inf", # 네트워크 - 가장 중요
"$virtio\viogpudo\w11\amd64\viogpudo.inf", # 화면 - 모니터 2개에 필요
"$virtio\Balloon\w11\amd64\balloon.inf",
"$virtio\vioserial\w11\amd64\vioser.inf",
"$virtio\viostor\w11\amd64\viostor.inf",
"$virtio\vioscsi\w11\amd64\vioscsi.inf",
"$virtio\vioinput\w11\amd64\vioinput.inf"
)
foreach ($inf in $infs) {
if (Test-Path $inf) {
$out = & pnputil.exe /add-driver $inf /install 2>&1 | Out-String
Say "pnputil $(Split-Path $inf -Leaf) -> $($out.Trim() -replace '\s+', ' ')"
} else {
Say "skip (없음) $inf"
}
}
# guest-tools 는 balloon 서비스/qemu-ga 까지 붙여준다. 실패해도 위에서 드라이버는 들어갔다.
try {
Say "virtio-win-guest-tools 설치 시작"
$p = Start-Process -FilePath "$virtio\virtio-win-guest-tools.exe" `
-ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
Say "virtio-win-guest-tools exit=$($p.ExitCode)"
} catch {
Say "virtio-win-guest-tools 실패: $($_.Exception.Message)"
}
}
# ---------------------------------------------------- 2) 화면 꺼짐 / 잠금 끄기
# 러너가 GUI 테스트와 스크린샷을 찍으므로 데스크톱이 살아 있어야 한다.
& powercfg.exe /change monitor-timeout-ac 0
& powercfg.exe /change standby-timeout-ac 0
& powercfg.exe /change disk-timeout-ac 0
& powercfg.exe /change hibernate-timeout-ac 0
Say "powercfg 적용 (모니터/대기/디스크/최대절전 전부 끔)"
# 화면 보호기 + 잠금화면
reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f | Out-Null
reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f | Out-Null
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization" /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
# 절전 시 암호 요구 끔
reg add "HKLM\SOFTWARE\Policies\Microsoft\Power\PowerSettings\0e796bdb-100d-47d6-a2d5-f7d2daa51f51" /v ACSettingIndex /t REG_DWORD /d 0 /f | Out-Null
Say "잠금화면/화면보호기 끔"
# 첫 부팅 때 뜨는 방해 요소들
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" /v NoDriveTypeAutoRun /t REG_DWORD /d 255 /f | Out-Null
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoRebootWithLoggedOnUsers /t REG_DWORD /d 1 /f | Out-Null
Say "자동실행/업데이트 자동재부팅 끔"
# ------------------------------------------- 3) agent 설치 + 로그온 예약 작업
$src = $PSScriptRoot
Copy-Item -Path (Join-Path $src 'agent.ps1') -Destination 'C:\win-ci\agent.ps1' -Force
Say "agent.ps1 복사 완료"
$taskCmd = 'powershell.exe'
$taskArg = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\win-ci\agent.ps1'
& schtasks.exe /Create /TN 'win-ci-agent' /SC ONLOGON /RL HIGHEST /TR "$taskCmd $taskArg" /F 2>&1 | Out-String | ForEach-Object { Say "schtasks -> $($_.Trim())" }
# ------------------------------------------------------------- 4) agent 기동
Say "agent 시작 - 이후 로그는 [agent] 접두어로 나온다"
Start-Process -FilePath $taskCmd -ArgumentList $taskArg -WindowStyle Hidden
Say "provision 끝"
if ($script:Port) { try { $script:Port.Close() } catch {} }

View File

@@ -0,0 +1,12 @@
[Unit]
Description=win-ci guest control channel (host -> Windows VM)
Before=win-ci.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/claude/win-ci/serve-host.py
Restart=always
RestartSec=5
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,20 @@
[Unit]
Description=win-ci Windows CI VM (nested KVM)
Wants=win-ci-http.service
After=win-ci-http.service
[Service]
Type=simple
WorkingDirectory=/home/claude/win-ci
ExecStart=/bin/bash /home/claude/win-ci/run-vm.sh
# 봇(ejclaw.service) cgroup 밖 - 안에서 돌면 MemoryHigh=16G 를 쳐서 봇이 OOM 으로 죽는다
MemoryMax=12G
# .5 의 운영 컨테이너 19개가 같은 물리 호스트를 쓴다. 4코어를 넘기면 .5 응답이 +140%
CPUQuota=400%
CPUWeight=50
Restart=on-failure
RestartSec=15
TimeoutStopSec=120
[Install]
WantedBy=default.target

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

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

14
src/livesub/__main__.py Normal file
View File

@@ -0,0 +1,14 @@
"""`python -m livesub` 와 포터블 exe 의 진입점.
**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이
최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import`
는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다.
exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다.
`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다.
"""
from livesub.app import main
if __name__ == "__main__":
raise SystemExit(main())

76
src/livesub/app.py Normal file
View File

@@ -0,0 +1,76 @@
"""애플리케이션 진입점."""
from __future__ import annotations
import contextlib
import logging
import sys
from pathlib import Path
from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False, to_file: bool = True) -> None:
"""로그 설정.
`to_file=False` 는 저장 위치를 아직 고르기 전에 쓴다. 그 시점에 파일
핸들러를 열면 사용자가 고르지도 않은 기본 폴더가 만들어지고 로그가
거기에 남는다.
"""
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
if to_file:
with contextlib.suppress(OSError):
handlers.append(
logging.FileHandler(user_data_dir() / "livesub.log", encoding="utf-8")
)
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
handlers=handlers,
force=True,
)
# 모델 라이브러리들이 INFO로 너무 시끄럽다.
for noisy in ("urllib3", "filelock", "huggingface_hub", "transformers"):
logging.getLogger(noisy).setLevel(logging.WARNING)
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
verbose = "--verbose" in argv or "-v" in argv
setup_logging(verbose, to_file=False)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from .ui import MainWindow
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
app = QApplication(argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName(ORG_NAME)
app.setQuitOnLastWindowClosed(True)
icon_path = Path(__file__).resolve().parent / "resources" / "icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
# 설정을 읽기 전에 어디서 읽을지부터 정해야 한다. 한 번 고르면 다음 실행부터
# 이 화면은 건너뛴다.
from .storage import is_configured
from .ui.data_location import ask_initial_location
if not is_configured() and not ask_initial_location():
return 0
setup_logging(verbose)
config = AppConfig.load()
window = MainWindow(config)
if not config.start_minimized:
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
"""오디오 캡처 레이어."""
from __future__ import annotations
import logging
from ..config import AudioConfig
from .base import AudioSource, CaptureBackend, CaptureError
from .file_source import FileCapture
from .input_device import InputDeviceCapture
from .process_loopback import ProcessLoopbackCapture
from .segmenter import Segment, Segmenter, SegmenterConfig
from .wasapi_loopback import WasapiLoopbackCapture
log = logging.getLogger(__name__)
#: 홈 화면 목록이 무엇을 보여줄지.
SOURCE_MODE_OUTPUT = "output" # 게임 등 프로그램 소리 + 출력 장치
SOURCE_MODE_INPUT = "input" # 마이크 등 입력 장치
__all__ = [
"SOURCE_MODE_INPUT",
"SOURCE_MODE_OUTPUT",
"AudioSource",
"CaptureBackend",
"CaptureError",
"FileCapture",
"InputDeviceCapture",
"ProcessLoopbackCapture",
"Segment",
"Segmenter",
"SegmenterConfig",
"WasapiLoopbackCapture",
"capture_capabilities",
"create_capture",
"list_sources",
]
def capture_capabilities() -> dict[str, bool]:
"""현재 환경에서 어떤 캡처가 가능한지."""
return {
"process": ProcessLoopbackCapture.available(),
"device": WasapiLoopbackCapture.available(),
"input": InputDeviceCapture.available(),
"file": True,
}
def list_sources(mode: str = SOURCE_MODE_OUTPUT) -> list[AudioSource]:
"""고른 쪽의 캡처 대상 목록.
출력(게임 소리)과 입력(마이크)을 한 목록에 섞으면 수십 줄이 되고, 정작
찾으려던 게임이 마이크·라인입력 사이에 파묻힌다. 그래서 한 번에 한 쪽만
보여주고 홈 화면에서 전환한다.
"""
if mode == SOURCE_MODE_INPUT:
return InputDeviceCapture.list_sources()
sources: list[AudioSource] = []
if ProcessLoopbackCapture.available():
sources.extend(ProcessLoopbackCapture.list_sources())
sources.extend(WasapiLoopbackCapture.list_sources())
return sources
def create_capture(config: AudioConfig) -> CaptureBackend:
"""설정에 맞는 캡처 백엔드를 만든다. 불가능하면 장치 루프백으로 폴백."""
backend = config.backend
if backend == "file":
return FileCapture(config.target_process_name)
if backend == "input":
if not InputDeviceCapture.available():
raise CaptureError(
"입력 장치를 열 수 없습니다. Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
)
return InputDeviceCapture(config.device_index)
want_process = backend in ("process", "auto") and config.target_pid > 0
if want_process:
if ProcessLoopbackCapture.available():
return ProcessLoopbackCapture(config.target_pid)
if backend == "process":
raise CaptureError(
"프로그램별 캡처를 쓰려면 livesub_capture.exe 가 필요합니다. "
"native/process_loopback/build.ps1 로 빌드하거나 '출력 장치 전체'를 선택하세요."
)
log.warning("프로그램별 캡처를 쓸 수 없어 장치 루프백으로 전환합니다.")
if not WasapiLoopbackCapture.available():
raise CaptureError(
"이 환경에서는 오디오 캡처를 할 수 없습니다. "
"Windows에서 PyAudioWPatch 설치 여부를 확인하세요."
)
return WasapiLoopbackCapture(config.device_index)

147
src/livesub/audio/base.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,106 @@
"""마이크 등 입력 장치 캡처 (Windows).
루프백(=스피커로 나가는 소리)이 아니라 **들어오는 소리**를 받는다. 통화나
합주처럼 상대 목소리가 내 스피커로 나오지 않는 상황, 또는 내가 말하는 것을
그대로 번역해 보고 싶을 때 쓴다.
WasapiLoopbackCapture 와 스트림을 여는 모양이 비슷하지만 합치지 않았다.
장치 목록을 만드는 방법(루프백 전용 열거 vs 일반 입력 장치)과 기본 장치를
고르는 방법이 서로 달라서, 한 클래스에 넣으면 분기가 코드의 절반이 된다.
"""
from __future__ import annotations
import contextlib
import logging
import numpy as np
from ..constants import FRAME_SAMPLES
from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k
log = logging.getLogger(__name__)
try: # pragma: no cover - 플랫폼 의존
import pyaudiowpatch as pyaudio
except ImportError: # pragma: no cover
pyaudio = None # type: ignore[assignment]
class InputDeviceCapture(CaptureBackend):
name = "input"
per_process = False
def __init__(self, device_index: int = -1) -> None:
super().__init__()
self.device_index = device_index
@staticmethod
def available() -> bool:
return pyaudio is not None
@staticmethod
def list_sources() -> list[AudioSource]:
if pyaudio is None:
return []
sources: list[AudioSource] = []
pa = pyaudio.PyAudio()
try:
default_index = -1
with contextlib.suppress(OSError, LookupError):
default_index = int(pa.get_default_input_device_info()["index"])
for i in range(pa.get_device_count()):
info = pa.get_device_info_by_index(i)
if int(info.get("maxInputChannels", 0)) <= 0:
continue
# 루프백 장치는 '출력 장치' 목록에서 이미 보여주고 있다.
# 여기서 또 내보내면 같은 것이 두 목록에 걸쳐 나온다.
if info.get("isLoopbackDevice"):
continue
sources.append(
AudioSource(
kind="input",
identifier=str(i),
label=str(info["name"]),
detail="기본 입력 장치" if i == default_index else "입력 장치",
)
)
finally:
pa.terminate()
return sources
def _resolve_device(self, pa) -> dict:
if self.device_index >= 0:
return pa.get_device_info_by_index(self.device_index)
return pa.get_default_input_device_info()
def _run(self) -> None: # pragma: no cover - 실제 오디오 장치 필요
if pyaudio is None:
raise CaptureError("PyAudioWPatch가 설치되지 않았습니다.")
pa = pyaudio.PyAudio()
stream = None
try:
info = self._resolve_device(pa)
if info is None:
raise CaptureError("입력 장치를 찾지 못했습니다.")
rate = int(info["defaultSampleRate"])
channels = max(1, int(info["maxInputChannels"]))
chunk = max(FRAME_SAMPLES, int(rate * 0.02))
stream = pa.open(
format=pyaudio.paFloat32,
channels=channels,
rate=rate,
input=True,
input_device_index=int(info["index"]),
frames_per_buffer=chunk,
)
log.info("입력 장치 캡처 시작: %s (%dHz, %dch)", info["name"], rate, channels)
while not self._stop.is_set():
raw = stream.read(chunk, exception_on_overflow=False)
data = np.frombuffer(raw, dtype=np.float32)
self._emit(to_mono_16k(data, channels, rate))
finally:
if stream is not None:
stream.stop_stream()
stream.close()
pa.terminate()

View File

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

View File

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

View File

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

207
src/livesub/config.py Normal file
View File

@@ -0,0 +1,207 @@
"""설정 모델과 JSON 영속화."""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from pathlib import Path
from typing import Any
from .constants import DEFAULT_TIER, user_data_dir
log = logging.getLogger(__name__)
def config_path() -> Path:
"""설정 파일 경로.
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
"""
return user_data_dir() / "config.json"
@dataclass
class SubtitleStyle:
"""자막 텍스트 표현 설정."""
font_family: str = "Pretendard"
font_size: int = 34
bold: bool = True
text_color: str = "#FFFFFF"
source_text_color: str = "#9AA4B2"
outline_color: str = "#000000"
outline_width: int = 3
background_color: str = "#000000"
background_opacity: int = 55 # 0-100
#: 글자와 배경 상자 가장자리 사이 여백. 배경 상자는 글자를 감싸는 크기로
#: 그려지므로 이 값이 곧 배경 크기를 정한다.
padding: int = 18
line_spacing: int = 6
max_lines: int = 2
show_source: bool = False # 원문 동시 표시
align: str = "center" # left | center | right
fade_out_ms: int = 4000 # 0이면 계속 유지
@dataclass
class OverlayConfig:
"""자막 창(오버레이) 위치/동작."""
x: int = -1 # -1 = 아직 배치 안 됨 (앵커로 계산)
y: int = -1
width: int = 1100
height: int = 200
always_on_top: bool = True
#: 자막 위로 마우스가 통과하게 한다. 게임 중에는 켜두는 게 편하다.
click_through: bool = True
#: 기본은 잠금이다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는다.
#: 옮기거나 크기를 바꿀 때는 자막 화면의 '자막 위치 조정'을 켠다.
locked: bool = True
#: 자막 표시 on/off (단축키로 토글)
visible: bool = True
#: 자막을 띄울 모니터 인덱스 (0부터)
screen_index: int = 0
#: 디스코드 오버레이식 9분할 위치. "custom"이면 드래그로 옮긴 자유 위치.
anchor: str = "bottom-center"
#: 화면 가장자리에서 띄울 간격
margin: int = 60
@dataclass
class AudioConfig:
backend: str = "auto" # auto | process | device | input | file
#: 홈 화면 목록이 무엇을 보여줄지. "output"=프로그램과 출력 장치(게임 소리),
#: "input"=마이크 같은 입력 장치. 고른 쪽을 기억한다.
source_mode: str = "output"
target_pid: int = 0
target_process_name: str = ""
device_index: int = -1 # -1 = 기본 출력 장치 루프백
input_gain: float = 1.0
vad_aggressiveness: int = 2 # 0-3
silence_ms: int = 600 # 이 시간만큼 조용하면 한 문장 종료
min_segment_ms: int = 400
max_segment_ms: int = 12_000
partial_interval_ms: int = 900 # 중간 결과 갱신 주기, 0이면 끔
@dataclass
class ModelConfig:
tier: str = DEFAULT_TIER
device: str = "cuda" # cuda | cpu
device_index: int = 0
source_lang: str = "auto" # auto 또는 언어코드
target_lang: str = "ko"
preload_on_start: bool = True
lora_adapter_path: str = "" # 추가학습 어댑터 (비어있으면 미사용)
#: 한국어 자막 말투. "polite"(존댓말, 기본) | "casual"(반말)
#: 반말이어도 원문이 실제로 존댓말이면 존댓말을 지킨다.
speech_level: str = "polite"
@dataclass
class GlossaryConfig:
enabled: bool = True
path: str = "" # 비어있으면 user_data_dir()/glossary.json
case_sensitive: bool = False
#: 처음 설치했을 때 켜져 있는 용어집 팩.
#: 체크를 안 해보고 그냥 쓰는 경우가 대부분이라 기본으로 켜둔다.
#: 게임 전용 팩끼리 역어가 갈리면 용어집 화면이 알려주므로, 안 하는 게임은
#: 꺼주면 된다.
enabled_packs: list[str] = field(
default_factory=lambda: ["fps-common", "overwatch", "pubg", "r6siege", "watchdogs"]
)
@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
class AppConfig:
audio: AudioConfig = field(default_factory=AudioConfig)
models: ModelConfig = field(default_factory=ModelConfig)
subtitle: SubtitleStyle = field(default_factory=SubtitleStyle)
overlay: OverlayConfig = field(default_factory=OverlayConfig)
glossary: GlossaryConfig = field(default_factory=GlossaryConfig)
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
# --- 영속화 ---------------------------------------------------------
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def save(self, path: Path | None = None) -> Path:
target = path or config_path()
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".json.tmp")
tmp.write_text(
json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
)
tmp.replace(target)
return target
@classmethod
def load(cls, path: Path | None = None) -> AppConfig:
target = path or config_path()
if not target.exists():
return cls()
try:
raw = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("설정 파일을 읽지 못해 기본값을 사용합니다: %s", exc)
return cls()
return _from_dict(cls, raw)
def _from_dict(cls: type, raw: Any) -> Any:
"""알 수 없는 키는 버리고 누락된 키는 기본값으로 채우는 관대한 역직렬화.
기본값 인스턴스를 먼저 만든 뒤 알고 있는 필드만 덮어쓴다. 덕분에
설정 스키마가 바뀌어도 사용자의 기존 config.json이 깨지지 않는다.
"""
instance = cls()
if not isinstance(raw, dict):
return instance
known = {f.name for f in fields(cls)}
for key, value in raw.items():
if key not in known:
continue
current = getattr(instance, key)
if is_dataclass(current) and not isinstance(current, type):
setattr(instance, key, _from_dict(type(current), value))
else:
setattr(instance, key, value)
return instance

59
src/livesub/constants.py Normal file
View File

@@ -0,0 +1,59 @@
"""전역 상수. 제품명 변경은 이 파일만 고치면 됩니다."""
from __future__ import annotations
from pathlib import Path
APP_NAME = "LiveSub"
APP_NAME_KO = "라이브섭"
APP_SLOGAN = "게임 소리를 실시간 자막으로"
APP_VERSION = "0.3.0"
ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개)
#
# 각 키의 의미:
# label — UI 표시 이름
# english — 프롬프트에 쓰는 영어 언어명
# nllb — NLLB-200 언어 코드 (FLORES-200 형식)
# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고
# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와
# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다.
LANGUAGES: dict[str, dict[str, str]] = {
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"},
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"},
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"},
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"},
}
LANGUAGE_CODES = tuple(LANGUAGES.keys())
# 기본 모델 품질 티어. 실제 티어 정의는 models/tiers.py 에 있지만,
# config 가 models 패키지를 import 하면 순환 의존이 생기므로 키만 여기에 둔다.
#
# 게임과 동시에 돌리는 것이 주 용도라 기본값을 '균형'(6GB)에서 '신속'(4GB)으로
# 낮췄다. 인식 품질은 그대로 최상급이고 번역만 경량이라 체감 손해가 가장 적다.
DEFAULT_TIER = "swift"
# 오디오 파이프라인 규격
SAMPLE_RATE = 16_000
CHANNELS = 1
FRAME_MS = 20
FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
def user_data_dir() -> Path:
"""설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다.
실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로
함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** —
설정에서 위치를 바꿨을 때 따라오지 않는다.
"""
from .storage import data_dir
return data_dir()
def models_dir() -> Path:
path = user_data_dir() / "models"
path.mkdir(parents=True, exist_ok=True)
return path

View File

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

351
src/livesub/core/engine.py Normal file
View File

@@ -0,0 +1,351 @@
"""실시간 번역 파이프라인 오케스트레이션.
캡처 스레드 → [오디오 큐] → 분할 스레드 → [구간 큐] → 인식/번역 워커 → 콜백
UI 프레임워크에 의존하지 않는다. Qt 쪽은 콜백을 시그널로 다시 던져준다.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
from collections.abc import Callable
import numpy as np
from ..audio import (
CaptureBackend,
CaptureError,
Segment,
Segmenter,
SegmenterConfig,
create_capture,
)
from ..config import AppConfig
from ..constants import LANGUAGE_CODES, user_data_dir
from ..models import Glossary, ModelManager
from ..models.manager import apply_vram_limit
from ..models.packs import build_glossary
from ..models.speech_level import (
SpeechLevel,
apply_speech_level,
detect_politeness,
fix_particles,
)
from .events import EngineState, EngineStatus, TranslationLine
log = logging.getLogger(__name__)
StatusCallback = Callable[[EngineStatus], None]
LineCallback = Callable[[TranslationLine], None]
class TranslationEngine:
def __init__(
self,
config: AppConfig,
on_line: LineCallback | None = None,
on_status: StatusCallback | None = None,
) -> None:
self.config = config
self._on_line = on_line
self._on_status = on_status
self.models = ModelManager(config.models, low_power=config.performance.game_mode)
self.glossary = self._load_glossary()
self._capture: CaptureBackend | None = None
self._segments: queue.Queue[Segment] = queue.Queue(maxsize=32)
self._threads: list[threading.Thread] = []
self._stop = threading.Event()
self._status = EngineStatus()
self._level = 0.0
#: 인식 정확도를 높이기 위해 직전 확정 문장을 프롬프트로 넘긴다.
self._last_final_text = ""
#: 마지막으로 실제 발화를 처리한 시각. 유휴 언로드 판단에 쓴다.
self._last_activity = time.monotonic()
self._models_unloaded = False
# --- 수명주기 -------------------------------------------------------
@property
def status(self) -> EngineStatus:
return self._status
@property
def running(self) -> bool:
return self._status.state in (EngineState.RUNNING, EngineState.LOADING)
def start(self) -> None:
if self.running:
return
self._stop.clear()
self._emit_status(EngineState.LOADING, "모델을 준비하고 있습니다…", 0.0)
thread = threading.Thread(target=self._bootstrap, name="engine-boot", daemon=True)
thread.start()
self._threads = [thread]
def stop(self) -> None:
if self._status.state is EngineState.IDLE:
return
self._emit_status(EngineState.STOPPING, "정지 중…")
self._stop.set()
if self._capture is not None:
self._capture.stop()
self._capture = None
for t in self._threads:
if t is not threading.current_thread():
t.join(timeout=3)
self._threads = []
self._drain_segments()
self._emit_status(EngineState.IDLE, "대기 중")
def shutdown(self) -> None:
self.stop()
self.models.unload()
def reload_glossary(self) -> None:
self.glossary = self._load_glossary()
def _speech_level(self) -> SpeechLevel:
"""설정 문자열을 열거형으로. 알 수 없는 값은 안전한 존댓말로."""
try:
return SpeechLevel(self.config.models.speech_level)
except ValueError:
return SpeechLevel.POLITE
def sync_performance(self) -> bool:
"""저부하 모드 설정을 모델 매니저에 반영한다. 바뀌었으면 True.
`ModelManager` 는 생성 시점의 `low_power` 로 연산 정밀도를 정한다.
설정에서 모드를 토글해도 이 값을 갱신하지 않으면 이미 올라가 있는
이전 정밀도 모델이 그대로 쓰여서, 껐는데 품질이 안 오르거나 켰는데
게임이 계속 버벅이는 상태가 된다. 그래서 값을 바꾸고 모델을 내려
다음 사용 때 새 사양으로 다시 올라가게 한다.
"""
want = self.config.performance.game_mode
if self.models.low_power == want:
return False
log.info("저부하 모드 %s — 모델을 새 정밀도로 다시 올립니다", "켬" if want else "끔")
self.models.unload()
self.models.low_power = want
return True
# --- 내부 스레드 ----------------------------------------------------
def _bootstrap(self) -> None:
try:
# 설정이 바뀐 채로 재시작됐을 수 있으므로 매 시작마다 맞춘다.
self.sync_performance()
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)
)
self._capture = create_capture(self.config.audio)
self._capture.start()
workers = [
threading.Thread(target=self._segment_loop, name="engine-segment", daemon=True),
threading.Thread(target=self._infer_loop, name="engine-infer", daemon=True),
]
for w in workers:
w.start()
self._threads.extend(workers)
kind = "프로그램별 캡처" if self._capture.per_process else "출력 장치 전체"
self._emit_status(EngineState.RUNNING, f"듣는 중 · {kind}", 1.0)
except (CaptureError, OSError, RuntimeError, ImportError) as exc:
log.exception("엔진 시작 실패")
self._emit_status(EngineState.ERROR, str(exc))
def _segment_loop(self) -> None:
cfg = self.config.audio
# 중간 결과는 같은 오디오를 여러 번 인식하므로 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=partial_ms,
vad_aggressiveness=cfg.vad_aggressiveness,
)
)
while not self._stop.is_set():
capture = self._capture
if capture is None:
break
chunk = capture.read(timeout=0.3)
if chunk is None:
if capture.error is not None:
self._emit_status(EngineState.ERROR, str(capture.error))
return
continue
if cfg.input_gain != 1.0:
chunk = np.clip(chunk * cfg.input_gain, -1.0, 1.0)
self._level = float(np.sqrt(np.mean(np.square(chunk, dtype=np.float64))))
for segment in segmenter.push(chunk):
self._enqueue(segment)
leftover = segmenter.flush()
if leftover is not None:
self._enqueue(leftover)
def _enqueue(self, segment: Segment) -> None:
try:
self._segments.put_nowait(segment)
except queue.Full:
# 밀렸다면 중간 결과부터 버린다. 확정 문장은 지켜야 한다.
if not segment.is_final:
return
try:
self._segments.get_nowait()
self._segments.put_nowait(segment)
except (queue.Empty, queue.Full):
pass
def _infer_loop(self) -> None:
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, 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
t0 = time.perf_counter()
transcript = recognizer.transcribe(
segment.audio,
language=source_lang,
fast=not segment.is_final,
prompt=self._last_final_text if segment.is_final else "",
)
asr_ms = int((time.perf_counter() - t0) * 1000)
if not transcript.text:
return
detected = transcript.language if transcript.language in LANGUAGE_CODES else mcfg.target_lang
target = mcfg.target_lang
translated = ""
mt_ms = 0
if segment.is_final:
# 중간 결과는 원문만 흘려보낸다. 번역은 확정 문장에만 돌려 GPU를 아낀다.
t1 = time.perf_counter()
# 말투는 원문을 보고 정한다. 원문이 실제로 존댓말이면 반말 모드여도
# 존댓말을 지켜야 하므로, 번역 전에 원문에서 높임을 읽어둔다.
politeness = detect_politeness(transcript.text, detected)
translated = translator.translate(
transcript.text,
detected,
target,
self.glossary if self.config.glossary.enabled else None,
speech_level=self._speech_level(),
source_politeness=politeness,
)
translated = apply_speech_level(
translated, target, self._speech_level(), politeness
)
if target == "ko":
translated = fix_particles(translated)
mt_ms = int((time.perf_counter() - t1) * 1000)
self._last_final_text = transcript.text
line = TranslationLine(
source_text=transcript.text,
translated_text=translated,
source_lang=detected,
target_lang=target,
is_final=segment.is_final,
latency_s=time.monotonic() - segment.ended_at,
asr_ms=asr_ms,
mt_ms=mt_ms,
)
if self._on_line:
self._on_line(line)
self._status.level = self._level
self._status.queue_depth = self._segments.qsize()
# --- 유틸 -----------------------------------------------------------
def _load_glossary(self) -> Glossary:
"""사용자 용어집 + 켜둔 기본 팩을 합쳐서 하나로 만든다."""
cfg = self.config.glossary
path = cfg.path or str(user_data_dir() / "glossary.json")
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():
try:
self._segments.get_nowait()
except queue.Empty:
break
def _emit_status(self, state: EngineState, message: str, progress: float | None = None) -> None:
self._status = EngineStatus(
state=state,
message=message,
progress=self._status.progress if progress is None else progress,
level=self._level,
queue_depth=self._segments.qsize(),
)
if self._on_status:
self._on_status(self._status)

View File

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

View File

@@ -0,0 +1,48 @@
"""모델 레이어 (음성인식 · 번역 · 용어집)."""
from .asr import SpeechRecognizer, Transcript
from .glossary import Glossary, GlossaryEntry
from .manager import GpuInfo, ModelManager, detect_gpu, recommended_tier
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .tiers import (
DEFAULT_TIER,
TIERS,
MTBackend,
PromptStyle,
Tier,
get_tier,
ordered_tiers,
)
from .translator import Translator, build_seedx_prompt, create_translator
__all__ = [
"DEFAULT_TIER",
"Glossary",
"GlossaryEntry",
"GpuInfo",
"MTBackend",
"ModelManager",
"Politeness",
"PromptStyle",
"SpeechLevel",
"SpeechRecognizer",
"TIERS",
"Tier",
"Transcript",
"Translator",
"apply_speech_level",
"build_seedx_prompt",
"detect_politeness",
"create_translator",
"detect_gpu",
"get_tier",
"ordered_tiers",
"recommended_tier",
"to_casual",
]

95
src/livesub/models/asr.py Normal file
View File

@@ -0,0 +1,95 @@
"""음성인식(ASR) 래퍼 — faster-whisper(CTranslate2) 기반."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
import numpy as np
from ..constants import models_dir
from .tiers import AsrSpec
log = logging.getLogger(__name__)
@dataclass
class Transcript:
text: str
language: str
confidence: float = 0.0
duration_s: float = 0.0
class SpeechRecognizer:
"""faster-whisper 모델 한 개를 감싼다. 스레드 안전."""
def __init__(self, spec: AsrSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._model = None
self._lock = threading.Lock()
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
from faster_whisper import WhisperModel
compute_type = self.spec.compute_type
if self.device == "cpu" and compute_type.endswith("float16"):
compute_type = "int8" # CPU에서는 float16이 오히려 느리다
log.info("음성인식 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute_type)
self._model = WhisperModel(
self.spec.repo,
device=self.device,
device_index=self.device_index,
compute_type=compute_type,
download_root=str(models_dir()),
)
def unload(self) -> None:
with self._lock:
self._model = None
def transcribe(
self,
audio: np.ndarray,
language: str | None = None,
fast: bool = False,
prompt: str = "",
) -> Transcript:
"""오디오 한 덩어리를 텍스트로.
`fast=True` 는 중간 결과용으로 beam search를 끄고 빠르게 돌린다.
"""
if self._model is None:
self.load()
assert self._model is not None
with self._lock:
segments, info = self._model.transcribe(
audio.astype(np.float32, copy=False),
language=language,
beam_size=1 if fast else self.spec.beam_size,
temperature=0.0,
condition_on_previous_text=False,
initial_prompt=prompt or None,
vad_filter=not fast,
vad_parameters={"min_silence_duration_ms": 300},
word_timestamps=False,
)
parts = [s.text for s in segments]
text = " ".join(p.strip() for p in parts if p.strip()).strip()
return Transcript(
text=text,
language=getattr(info, "language", language or "") or "",
confidence=float(getattr(info, "language_probability", 0.0) or 0.0),
duration_s=float(getattr(info, "duration", 0.0) or 0.0),
)

View File

@@ -0,0 +1,235 @@
"""용어집(고유명사·게임/방송 용어) 처리.
추가학습 없이도 즉시 효과를 내는 레이어다. 두 가지 방식으로 동작한다.
1. 플레이스홀더 보호 (`protect` / `restore`)
원문의 등록 용어를 `⟦0⟧` 같은 토큰으로 치환해 번역 모델이 건드리지 못하게 하고,
번역이 끝난 뒤 지정한 역어로 되돌린다. 어떤 모델에서도 쓸 수 있다.
2. 프롬프트 주입 (`prompt_hint`)
해당 문장에 등장한 용어만 골라 프롬프트에 "이 용어는 이렇게 옮겨라"로 넣는다.
조사·어미까지 문맥에 맞게 붙어 결과가 더 자연스럽다.
어느 쪽을 쓸지는 모델이 정한다. 2번은 **지시문을 이해하는 instruct 모델에서만**
동작한다. Seed-X 처럼 번역만 하도록 학습된 completion 모델은 지시문을 넣으면
학습 분포를 벗어나 오히려 번역이 망가지므로 1번을 쓴다. NLLB 같은 seq2seq 도
마찬가지다. 판정은 `MTSpec.supports_prompt_glossary` 가 한다.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
#: 번역 모델이 원문 그대로 통과시켜야 하는 자리표시자.
#:
#: 형식 선택은 취향이 아니라 실측 결과다. 처음에 쓰던 `⟦0⟧` 는 NLLB 가
#: 괄호를 통째로 날려 `0` 만 남겼고(생존률 0/3), 그 결과 용어가 자막에서
#: 사라졌다. 후보 8종을 실제 모델로 돌려 비교한 끝에 `#0#` 를 골랐다
#: (생존률 3/3, 다중 치환 4/5). 입력이 음성인식 결과라 `#` 가 자연스럽게
#: 등장할 일이 거의 없다는 점도 같이 봤다.
PLACEHOLDER = "#{}#" # #0#
PLACEHOLDER_RE = re.compile(r"#(\d+)#")
@dataclass
class GlossaryEntry:
"""용어 하나. `targets` 는 언어코드 → 역어."""
source: str
targets: dict[str, str] = field(default_factory=dict)
note: str = ""
#: 비우면 모든 소스 언어에 적용
source_lang: str = ""
#: 문장 전체가 이 표현일 때만 치환한다.
#:
#: "get in the car" 같은 절을 긴 문장 한가운데서 통째로 치환하면
#: 번역 모델이 문법을 세울 근거를 잃어 "어부 들어오는, 차 타!" 같은
#: 결과가 나온다. 반대로 그 말만 단독으로 나왔을 때는 통째로 바꾸는
#: 것이 가장 자연스럽다. 그래서 적용 범위를 문장 전체로 한정한다.
whole_only: bool = False
def target_for(self, lang: str) -> str:
return self.targets.get(lang, "")
class Glossary:
def __init__(self, entries: list[GlossaryEntry] | None = None, case_sensitive: bool = False):
self.case_sensitive = case_sensitive
self.entries: list[GlossaryEntry] = list(entries or [])
self._pattern: re.Pattern[str] | None = None
self._index: dict[str, GlossaryEntry] = {}
self._rebuild()
# --- 영속화 --------------------------------------------------------
@classmethod
def load(cls, path: str | Path, case_sensitive: bool = False) -> Glossary:
p = Path(path)
if not p.is_file():
return cls(case_sensitive=case_sensitive)
raw = json.loads(p.read_text(encoding="utf-8"))
entries = [
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=item.get("note", ""),
source_lang=item.get("source_lang", ""),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
]
return cls(entries, case_sensitive=raw.get("case_sensitive", case_sensitive))
def save(self, path: str | Path) -> Path:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"case_sensitive": self.case_sensitive,
"entries": [
{
"source": e.source,
"targets": e.targets,
"note": e.note,
"source_lang": e.source_lang,
"whole_only": e.whole_only,
}
for e in self.entries
],
}
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return p
# --- 편집 ----------------------------------------------------------
def add(self, entry: GlossaryEntry) -> None:
key = self._key(entry.source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self.entries.append(entry)
self._rebuild()
def remove(self, source: str) -> None:
key = self._key(source)
self.entries = [e for e in self.entries if self._key(e.source) != key]
self._rebuild()
def __len__(self) -> int:
return len(self.entries)
# --- 사용 ----------------------------------------------------------
def matches(self, text: str, target_lang: str) -> list[GlossaryEntry]:
"""문장에 실제로 등장하고 해당 언어 역어가 있는 항목만."""
if not self._pattern or not text:
return []
found: list[GlossaryEntry] = []
seen: set[str] = set()
for m in self._pattern.finditer(text):
entry = self._index.get(self._key(m.group(0)))
if entry is None or not entry.target_for(target_lang):
continue
key = self._key(entry.source)
if key not in seen:
seen.add(key)
found.append(entry)
return found
def protect(self, text: str, target_lang: str) -> tuple[str, list[str]]:
"""등록 용어를 자리표시자로 치환. (치환된 문장, 역어 목록) 반환."""
replacements: list[str] = []
if not text:
return text, replacements
whole = self._match_whole(text, target_lang)
if whole is not None:
# 문장 전체가 등록된 표현 — 통째로 바꾸는 게 가장 자연스럽다.
return PLACEHOLDER.format(0), [whole]
if not self._pattern:
return text, replacements
def _sub(m: re.Match[str]) -> str:
entry = self._index.get(self._key(m.group(0)))
if entry is None:
return m.group(0)
target = entry.target_for(target_lang)
if not target:
return m.group(0)
replacements.append(target)
return PLACEHOLDER.format(len(replacements) - 1)
return self._pattern.sub(_sub, text), replacements
@staticmethod
def surviving_placeholders(text: str) -> set[int]:
"""번역 결과에 살아남은 자리표시자 번호."""
return {int(m) for m in PLACEHOLDER_RE.findall(text)}
@classmethod
def missing_placeholders(cls, text: str, replacements: list[str]) -> list[int]:
"""번역 중에 사라진 자리표시자 번호.
번역 모델이 문장 일부를 통째로 누락하면 자리표시자도 같이 사라진다.
그대로 복원하면 해당 용어가 자막에서 증발하므로, 호출부가 이걸로
확인해 용어집 없이 다시 번역하는 쪽을 택할 수 있게 한다.
"""
present = cls.surviving_placeholders(text)
return [i for i in range(len(replacements)) if i not in present]
@staticmethod
def restore(text: str, replacements: list[str]) -> str:
"""플레이스홀더를 역어로 되돌린다."""
if not replacements:
return text
def _sub(m: re.Match[str]) -> str:
idx = int(m.group(1))
return replacements[idx] if 0 <= idx < len(replacements) else ""
return PLACEHOLDER_RE.sub(_sub, text)
def prompt_hint(self, text: str, target_lang: str, limit: int = 12) -> str:
"""LLM 프롬프트에 붙일 용어 지시문. 해당 없으면 빈 문자열."""
hits = self.matches(text, target_lang)[:limit]
if not hits:
return ""
lines = [f"- {e.source} -> {e.target_for(target_lang)}" for e in hits]
return "다음 용어는 반드시 지정된 역어로 번역하세요:\n" + "\n".join(lines)
def _match_whole(self, text: str, target_lang: str) -> str | None:
"""문장 전체가 등록 표현과 같으면 그 역어를 돌려준다."""
stripped = self._key(text.strip().rstrip(".!?…~,"))
entry = self._index.get(stripped)
if entry is None:
return None
return entry.target_for(target_lang) or None
# --- 내부 ----------------------------------------------------------
def _key(self, text: str) -> str:
return text if self.case_sensitive else text.casefold()
def _rebuild(self) -> None:
self._index = {self._key(e.source): e for e in self.entries}
if not self.entries:
self._pattern = None
return
# whole_only 항목은 부분 매칭에서 제외한다 (문장 전체일 때만 쓴다).
partial = [e.source for e in self.entries if not e.whole_only]
if not partial:
self._pattern = None
return
# 긴 용어를 먼저 매칭해야 "헤드샷"이 "헤드"에 잡아먹히지 않는다.
sources = sorted(partial, key=len, reverse=True)
flags = 0 if self.case_sensitive else re.IGNORECASE
self._pattern = re.compile(
"|".join(_bounded(re.escape(s)) for s in sources), flags
)
def _bounded(escaped: str) -> str:
"""영문/숫자로 시작·끝나는 용어에만 단어 경계를 건다.
한국어·일본어·중국어에는 \\b 가 의미 없으므로 적용하지 않는다.
"""
prefix = r"\b" if re.match(r"[A-Za-z0-9]", escaped.replace("\\", "")) else ""
suffix = r"\b" if re.search(r"[A-Za-z0-9]$", escaped.replace("\\", "")) else ""
return f"{prefix}{escaped}{suffix}"

View File

@@ -0,0 +1,271 @@
"""티어에 맞는 ASR + MT 모델 쌍을 관리한다."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, replace
from ..config import ModelConfig
from .asr import SpeechRecognizer
from .tiers import DEFAULT_TIER, TIERS, MTBackend, Tier, get_tier
from .translator import Translator, create_translator
log = logging.getLogger(__name__)
@dataclass
class GpuInfo:
available: bool
name: str = ""
total_vram_mb: int = 0
free_vram_mb: int = 0
reason: str = ""
def detect_gpu() -> GpuInfo:
"""CUDA 사용 가능 여부와 VRAM. torch 가 없으면 nvidia-smi 로 대체 조회."""
try:
import torch
if torch.cuda.is_available():
idx = torch.cuda.current_device()
free, total = torch.cuda.mem_get_info(idx)
return GpuInfo(
available=True,
name=torch.cuda.get_device_name(idx),
total_vram_mb=total // (1024 * 1024),
free_vram_mb=free // (1024 * 1024),
)
return GpuInfo(False, reason="CUDA를 사용할 수 없습니다 (드라이버/빌드 확인).")
except ImportError:
pass
import shutil
import subprocess
smi = shutil.which("nvidia-smi")
if not smi:
return GpuInfo(False, reason="PyTorch와 nvidia-smi 모두 찾지 못했습니다.")
try:
out = subprocess.run(
[smi, "--query-gpu=name,memory.total,memory.free",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5, check=True,
).stdout.strip().splitlines()
name, total, free = (v.strip() for v in out[0].split(","))
return GpuInfo(True, name, int(total), int(free),
reason="PyTorch 미설치 — 정보만 조회했습니다.")
except Exception as exc: # noqa: BLE001
# 예외 문자열에는 실행한 명령줄이 통째로 들어 있다. 그대로 화면에
# 띄우면 안내가 아니라 로그 덤프가 된다. 자세한 건 로그로 보낸다.
log.warning("nvidia-smi 조회 실패: %s", exc)
return GpuInfo(False, reason="nvidia-smi 로 GPU 정보를 읽지 못했습니다.")
def apply_vram_limit(ratio: float, device_index: int = 0) -> bool:
"""이 프로세스가 쓸 수 있는 VRAM 상한을 건다.
게임과 동시에 돌 때 가장 큰 문제는 연산 시간이 아니라 **VRAM 고갈**이다.
게임이 쓸 메모리를 우리가 먹어버리면 텍스처 스트리밍이 시스템 메모리로
밀려나면서 프레임이 뚝뚝 끊긴다. 상한을 걸면 그 대신 우리 쪽이 먼저
OOM 을 맞으므로, 티어를 낮추라는 안내로 이어진다.
"""
if ratio <= 0 or ratio >= 1:
return False
try:
import torch
if not torch.cuda.is_available():
return False
torch.cuda.set_per_process_memory_fraction(ratio, device_index)
log.info("VRAM 상한 적용: 전체의 %d%%", int(ratio * 100))
return True
except Exception as exc: # noqa: BLE001 - 상한 실패가 치명적이지는 않다
log.warning("VRAM 상한을 걸지 못했습니다: %s", exc)
return False
def has_torch() -> bool:
"""PyTorch 가 설치돼 있는가.
포터블 exe 빌드는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라
한 파일로 묶으면 실행할 때마다 그걸 임시폴더에 풀어야 해서 쓸 수 없다.
CTranslate2 기반 1~3티어는 torch 없이 동작하므로, 포터블은 그쪽만 담는다.
"""
import importlib.util
return importlib.util.find_spec("torch") is not None
def has_awq() -> bool:
"""4티어(AWQ Int4) 로드에 필요한 autoawq 가 있는지."""
import importlib.util
return importlib.util.find_spec("awq") is not None
def tier_availability(tier: Tier, gpu: GpuInfo | None = None) -> tuple[bool, str]:
"""이 설치 환경에서 해당 티어를 쓸 수 있는지와, 안 되면 그 이유.
torch 유무만 보면 부족하다. 실제로 막히는 경우가 둘 더 있다.
- 4티어(AWQ Int4)는 autoawq 가 있어야 로드된다. requirements.txt 에
없는 선택 의존성이라 없을 때가 많다.
- VRAM 이 모자라면 설치가 아무리 멀쩡해도 안 올라간다. 기존에도
min_vram_gb 로 경고 배지는 띄웠지만 선택은 막지 않아서, 골랐다가
로드에서 죽었다. 예: RTX 5050(7.5GB)은 정밀 10GB / 극한 16GB 라
둘 다 영영 못 쓴다.
"사용 가능"이라고 해놓고 로드에서 죽으면 사용자는 원인을 알 수 없다.
그래서 고를 수 있다고 말하기 전에 여기서 걸러낸다.
"""
if tier.mt.backend is not MTBackend.TRANSFORMERS:
return True, ""
if not has_torch():
return False, "PyTorch가 없어 사용할 수 없습니다 (포터블 버전은 1~3티어만 지원)"
if tier.mt.compute_type == "int4" and not has_awq():
return False, "autoawq가 없어 사용할 수 없습니다 (pip install autoawq)"
info = detect_gpu() if gpu is None else gpu
# free 가 아니라 total 로 본다. free 는 다른 프로그램 때문에 수시로
# 변해서, 그걸로 막으면 "아까는 되던 티어가 지금은 안 보인다"가 된다.
have_gb = info.total_vram_mb / 1024
if info.available and have_gb < tier.min_vram_gb:
return False, (
f"VRAM이 부족합니다 "
f"({tier.min_vram_gb:g}GB 필요, 이 GPU는 {have_gb:.1f}GB)"
)
return True, ""
def recommended_tier(gpu: GpuInfo | None = None) -> str:
"""이 환경에서 실제로 권할 만한 티어.
티어 표에 박아둔 `recommended` 는 "GPU 가 넉넉할 때" 를 전제로 한 값이라,
그대로 쓰면 GPU 가 없는 사람에게 10GB 짜리를 추천하게 된다. 그래서 환경을
보고 정한다.
GPU 가 없으면 CTranslate2 계열(1~3티어)만 후보에 둔다. LLM 계열은 CPU 에서
한 문장에 수십 초가 걸려 실시간 자막으로 쓸 수 없다. 그중에서도 가장
가벼운 쪽을 고른다 — CPU 에서는 연산량이 그대로 지연으로 돌아온다.
"""
info = detect_gpu() if gpu is None else gpu
usable = [t for t in TIERS.values() if tier_availability(t, info)[0]]
if not usable:
return DEFAULT_TIER
if not info.available:
light = [t for t in usable if t.mt.backend is MTBackend.CTRANSLATE2]
return min(light or usable, key=lambda t: t.order).key
preferred = [t for t in usable if t.recommended]
if preferred:
return max(preferred, key=lambda t: t.order).key
return max(usable, key=lambda t: t.order).key
def first_available_tier(preferred: str) -> str:
"""선호 티어가 불가능하면 쓸 수 있는 가장 좋은 티어로 내린다."""
tier = get_tier(preferred)
gpu = detect_gpu()
if tier_availability(tier, gpu)[0]:
return tier.key
usable = [t for t in TIERS.values() if tier_availability(t, gpu)[0]]
if not usable:
return preferred
return max(usable, key=lambda t: t.order).key
def downgrade_compute(compute_type: str) -> str:
"""저부하 모드에서 쓸 더 가벼운 연산 정밀도."""
if compute_type in ("float16", "float32"):
return "int8_float16"
return compute_type
class ModelManager:
"""현재 선택된 티어의 모델 두 개를 들고 있는다."""
def __init__(self, config: ModelConfig, low_power: bool = False) -> None:
self.config = config
#: 저부하(게임) 모드. 연산 정밀도를 낮춰 VRAM과 GPU 점유를 줄인다.
self.low_power = low_power
self._tier: Tier = get_tier(config.tier)
self._asr: SpeechRecognizer | None = None
self._mt: Translator | None = None
self._lock = threading.Lock()
@property
def tier(self) -> Tier:
return self._tier
@property
def ready(self) -> bool:
return bool(self._asr and self._asr.loaded and self._mt and self._mt.loaded)
def effective_asr_spec(self):
"""저부하 모드가 켜져 있으면 정밀도를 낮춘 사양을 돌려준다."""
spec = self._tier.asr
if not self.low_power:
return spec
return replace(
spec, compute_type=downgrade_compute(spec.compute_type), beam_size=1
)
def effective_mt_spec(self):
spec = self._tier.mt
# 양자화 LLM(int4)은 이미 최소 상태라 더 낮출 수 없다.
if not self.low_power or spec.compute_type == "int4":
return spec
return replace(spec, compute_type=downgrade_compute(spec.compute_type))
def recognizer(self) -> SpeechRecognizer:
with self._lock:
if self._asr is None:
self._asr = SpeechRecognizer(
self.effective_asr_spec(), self.config.device, self.config.device_index
)
return self._asr
def translator(self) -> Translator:
with self._lock:
if self._mt is None:
self._mt = create_translator(
self.effective_mt_spec(),
self.config.device,
self.config.device_index,
self.config.lora_adapter_path,
)
return self._mt
def preload(self, progress=None) -> None:
"""두 모델을 미리 올린다. progress(str, float) 콜백으로 상태를 보고."""
def report(msg: str, value: float) -> None:
if progress:
progress(msg, value)
report(f"'{self._tier.name}' 음성인식 모델 준비 중…", 0.1)
self.recognizer().load()
report(f"'{self._tier.name}' 번역 모델 준비 중…", 0.6)
self.translator().load()
report("준비 완료", 1.0)
def switch_tier(self, tier_key: str) -> Tier:
"""티어 변경. 기존 모델은 내려서 VRAM을 즉시 돌려준다."""
if tier_key == self._tier.key:
return self._tier
self.unload()
self.config.tier = tier_key
self._tier = get_tier(tier_key)
log.info("모델 티어 변경: %s", self._tier.name)
return self._tier
def unload(self) -> None:
with self._lock:
if self._asr is not None:
self._asr.unload()
self._asr = None
if self._mt is not None:
self._mt.unload()
self._mt = None

152
src/livesub/models/packs.py Normal file
View File

@@ -0,0 +1,152 @@
"""기본 제공 게임 용어집 팩.
`resources/glossaries/*.json` 에 게임별로 묶어둔 용어를 읽어, 사용자가 켠 팩만
사용자 용어집과 합쳐 하나의 `Glossary` 로 만든다.
합칠 때 **사용자가 직접 등록한 항목이 항상 이긴다.** 기본 팩의 역어가 마음에
안 들면 용어집 화면에서 같은 단어를 등록하면 그쪽이 적용된다.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from .glossary import Glossary, GlossaryEntry
log = logging.getLogger(__name__)
PACK_DIR = Path(__file__).resolve().parent.parent / "resources" / "glossaries"
#: 여러 팩을 동시에 켰을 때 같은 단어의 역어가 갈리는 것을 막기 위한 계층.
#: 숫자가 클수록 나중에 적용되어 이긴다.
SCOPE_PRIORITY = {"generic": 0, "game": 1}
@dataclass(frozen=True)
class GlossaryPack:
key: str
name: str
description: str
entries: tuple[GlossaryEntry, ...]
#: "generic" = 장르 공통, "game" = 특정 게임 전용
scope: str = "game"
def __len__(self) -> int:
return len(self.entries)
@property
def priority(self) -> int:
return SCOPE_PRIORITY.get(self.scope, 1)
@property
def is_generic(self) -> bool:
return self.scope == "generic"
@lru_cache(maxsize=1)
def available_packs() -> tuple[GlossaryPack, ...]:
"""번들된 팩 목록. 파일이 깨져 있으면 그 팩만 건너뛴다."""
if not PACK_DIR.is_dir():
return ()
packs: list[GlossaryPack] = []
for path in sorted(PACK_DIR.glob("*.json")):
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("용어집 팩을 읽지 못했습니다 (%s): %s", path.name, exc)
continue
entries = tuple(
GlossaryEntry(
source=item["source"],
targets=dict(item.get("targets", {})),
note=raw.get("name", path.stem),
whole_only=bool(item.get("whole_only", False)),
)
for item in raw.get("entries", [])
if item.get("source")
)
if not entries:
continue
packs.append(
GlossaryPack(
key=raw.get("key", path.stem),
name=raw.get("name", path.stem),
description=raw.get("description", ""),
entries=entries,
scope=raw.get("scope", "game"),
)
)
return tuple(packs)
def get_pack(key: str) -> GlossaryPack | None:
for pack in available_packs():
if pack.key == key:
return pack
return None
def resolve_packs(enabled_packs: list[str]) -> list[GlossaryPack]:
"""켜둔 팩을 적용 순서대로 돌려준다 (범용 먼저, 게임 전용 나중).
`knocked` 는 FPS 공통에서 "기절", 배그에서 "눕혔다"다. 어느 쪽이 맞는지는
지금 무슨 게임을 하느냐로 갈리므로, **게임 전용 팩이 항상 범용을 이겨야**
한다. 그렇지 않으면 체크박스를 누른 순서에 따라 결과가 달라진다.
"""
packs: list[GlossaryPack] = []
for key in enabled_packs:
pack = get_pack(key)
if pack is None:
log.warning("알 수 없는 용어집 팩입니다: %s", key)
continue
packs.append(pack)
# 같은 계층 안에서는 사용자가 켠 순서를 유지한다 (stable sort).
return sorted(packs, key=lambda p: p.priority)
def find_conflicts(enabled_packs: list[str], lang: str = "ko") -> dict[str, list[tuple[str, str]]]:
"""켜둔 게임 전용 팩끼리 역어가 갈리는 단어를 찾는다.
범용 팩은 게임 팩에 확실히 지므로 충돌로 보지 않는다. 게임 팩 둘 이상이
같은 단어를 다르게 번역하면 그건 사용자가 판단해야 할 문제라 UI로 알린다.
"""
seen: dict[str, list[tuple[str, str]]] = {}
for pack in resolve_packs(enabled_packs):
if pack.is_generic:
continue
for entry in pack.entries:
target = entry.target_for(lang)
if not target:
continue
seen.setdefault(entry.source.casefold(), []).append((pack.name, target))
return {
source: owners
for source, owners in seen.items()
if len({t for _, t in owners}) > 1
}
def build_glossary(
user_glossary: Glossary,
enabled_packs: list[str],
case_sensitive: bool = False,
) -> Glossary:
"""켜둔 팩 + 사용자 용어집을 하나로 합친다.
적용 순서: 범용 팩 → 게임 전용 팩 → 사용자 항목.
뒤에 오는 쪽이 이기므로 사용자가 직접 등록한 것이 항상 최우선이다.
"""
merged: list[GlossaryEntry] = []
for pack in resolve_packs(enabled_packs):
merged.extend(pack.entries)
merged.extend(user_glossary.entries)
combined = Glossary(case_sensitive=case_sensitive)
for entry in merged:
combined.add(entry) # add() 가 같은 원문을 교체해준다
return combined

View File

@@ -0,0 +1,310 @@
"""말투(존댓말/반말) 처리.
규칙은 두 줄로 요약된다.
1. 원문이 **실제로 존댓말**이면 반말 모드여도 존댓말을 지킨다.
상대가 정중하게 말했는데 자막이 반말로 나오면 뉘앙스가 통째로 뒤집힌다.
2. 원문에 높임이 **없거나 알 수 없으면** 사용자가 고른 모드를 따른다.
영어·중국어는 문법적 높임이 없으므로 항상 여기에 해당한다.
한국어로 번역할 때만 동작한다. 영어·중국어는 대상 언어일 때 적용할 문법이
없고, 일본어 경어 변환은 동사 활용이 필요해 규칙만으로는 위험하다.
변환 방향도 한쪽만 한다. 번역 모델의 한국어 출력은 거의 항상 격식체
(합니다체/해요체)이므로 **존댓말 모드는 손대지 않고**, 반말 모드일 때만
낮춤 변환을 건다. 반대 방향(반말→존댓말)은 어간 정보가 없으면 훨씬 자주
틀리므로 시도하지 않는다.
"""
from __future__ import annotations
import re
from enum import Enum
class SpeechLevel(str, Enum):
"""사용자가 고르는 출력 말투."""
POLITE = "polite" # 존댓말 (기본)
CASUAL = "casual" # 반말
class Politeness(str, Enum):
"""원문에서 감지한 높임 정도."""
UNKNOWN = "unknown" # 높임이 없는 언어이거나 판단 불가
POLITE = "polite" # 확실히 존댓말
CASUAL = "casual" # 확실히 반말
#: 문법적 높임 체계가 있는 언어. 나머지는 항상 UNKNOWN 이다.
HONORIFIC_LANGUAGES = frozenset({"ko", "ja"})
# --- 원문 높임 감지 ---------------------------------------------------------
_KO_POLITE = re.compile(
r"(습니다|습니까|ㅂ니다|ㅂ니까|십시오|십시요|세요|셔요|시죠"
r"|어요|아요|에요|예요|해요|네요|지요|죠|드려|드립|주세|감사합)"
)
_JA_POLITE = re.compile(
r"(です|ます|ません|でした|ました|ましょう|ください|下さい"
r"|ございま|でしょう|いたし|申し上げ|なさい)"
)
_HAS_HANGUL = re.compile(r"[가-힣]")
_HAS_JAPANESE = re.compile(r"[぀-ヿ一-鿿]")
def detect_politeness(text: str, lang: str) -> Politeness:
"""원문의 높임 정도를 판단한다.
높임 체계가 없는 언어(영어·중국어)는 항상 UNKNOWN 이다. "please" 같은
공손 표현은 문법적 높임이 아니라 어조라, 이걸로 존댓말을 확정하면
오탐이 너무 많아진다.
한국어·일본어는 존댓말 표지가 있으면 POLITE, 없으면 CASUAL 로 본다.
두 언어 모두 문장은 둘 중 하나로 끝나므로 "중립"이라는 선택지가 사실상
없고, 어미를 일일이 열거하는 것보다 이 편이 덜 틀린다.
"""
text = (text or "").strip()
if not text or lang not in HONORIFIC_LANGUAGES:
return Politeness.UNKNOWN
polite, script = (
(_KO_POLITE, _HAS_HANGUL) if lang == "ko" else (_JA_POLITE, _HAS_JAPANESE)
)
if polite.search(text):
return Politeness.POLITE
if script.search(text):
return Politeness.CASUAL
return Politeness.UNKNOWN
# --- 한국어 낮춤 변환 -------------------------------------------------------
_HANGUL_BASE = 0xAC00
_JONGSUNG_COUNT = 28
_JUNGSUNG_COUNT = 21
_HANGUL_COUNT = 11172
# 중성 인덱스
_A, _AE, _EO, _E, _YEO, _O, _WA, _U, _WO, _EU, _I = 0, 1, 4, 5, 6, 8, 9, 13, 14, 18, 20
#: '아'로 이어지는 밝은 모음 (모음조화)
_BRIGHT = frozenset({_A, _O, 2, _WA})
#: 받침 없는 어간 + 어/아 의 축약. 오+아 -> 와, 지+어 -> 져
_CONTRACTION = {_O: _WA, _U: _WO, _I: _YEO, _EU: _EO}
#: 붙여도 모양이 그대로인 어간 모음. 가+아 -> 가, 서+어 -> 서
_ABSORBING = frozenset({_A, _EO, _AE, _E, _YEO, _WA, _WO})
_RIEUL = 8 # 종성 ㄹ
_SSANG_SIOT = 20 # 종성 ㅆ — 과거형 '았/었' 판별용
def _decompose(char: str) -> tuple[int, int, int] | None:
code = ord(char) - _HANGUL_BASE
if not (0 <= code < _HANGUL_COUNT):
return None
return (
code // (_JUNGSUNG_COUNT * _JONGSUNG_COUNT),
(code // _JONGSUNG_COUNT) % _JUNGSUNG_COUNT,
code % _JONGSUNG_COUNT,
)
def _compose(cho: int, jung: int, jong: int) -> str:
return chr(
_HANGUL_BASE + (cho * _JUNGSUNG_COUNT + jung) * _JONGSUNG_COUNT + jong
)
def _attach_eo(stem: str) -> str:
"""어간에 어/아 를 붙인다 (해체 활용).
먹->먹어, 좋->좋아, 하->해, 오->와, 주->줘, 빠지->빠져, 가->가.
한국어 활용의 실제 규칙(모음조화 + 축약)을 그대로 구현한 것이라
어미 목록을 나열하는 방식보다 훨씬 넓게 맞는다.
"""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "어"
cho, jung, jong = parts
if jong == _SSANG_SIOT:
# 과거형 '았/었/였' 뒤에는 모음조화와 무관하게 항상 '어'가 온다.
# (남았아 X -> 남았어 O)
return stem + "어"
if jong: # 받침이 있으면 그냥 붙인다
return stem + ("아" if jung in _BRIGHT else "어")
if cho == 18 and jung == _A: # 하 -> 해
return stem[:-1] + _compose(cho, _AE, 0)
if jung in _CONTRACTION: # 오+아 -> 와, 지+어 -> 져
return stem[:-1] + _compose(cho, _CONTRACTION[jung], 0)
if jung in _ABSORBING: # 가+아 -> 가
return stem
return stem + ("아" if jung in _BRIGHT else "어")
def _attach_lge(stem: str) -> str:
"""'~겠습니다' -> '~ㄹ게'. 치->칠게, 가->갈게, 먹->먹을게."""
if not stem:
return stem
parts = _decompose(stem[-1])
if parts is None:
return stem + "을게"
cho, jung, jong = parts
if jong:
return stem + "을게"
return stem[:-1] + _compose(cho, jung, _RIEUL) + "게"
#: 받침 없는 어간 + ㅂ니다 축약형. 규칙으로 풀기 어려운 것만 표로 둔다.
_CONTRACTED = {
"입니다": "야",
"됩니다": "돼",
"드립니다": "줄게",
}
#: (패턴, 치환) — 긴 어미부터 적용해야 짧은 어미가 먼저 먹지 않는다.
_CASUAL_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"있습니다"), "있어"),
(re.compile(r"없습니다"), "없어"),
(re.compile(r"이에요"), "이야"),
(re.compile(r"예요"), "야"),
(re.compile(r"네요"), "네"),
(re.compile(r"지요"), "지"),
(re.compile(r"죠"), "지"),
]
#: 어간을 활용해야 하는 어미들. 앞 글자를 잡아 _attach_* 로 넘긴다.
_GESSEUMNIDA = re.compile(r"([가-힣])겠습니다")
_SEUMNIDA = re.compile(r"([가-힣])습니다")
_BNIDA = re.compile(r"([가-힣])ᆸ니다|([가-힣])ㅂ니다")
_SEYO = re.compile(r"([가-힣])세요")
_SEUMNIKKA = re.compile(r"([가-힣])습니까")
#: 명령형 존대 '~십시오' 도 어간을 활용해야 한다. 조심하십시오 -> 조심해
_SIPSIO = re.compile(r"([가-힣])십시[오요]")
#: 문장 끝의 '요'만 떼어낸다. 문장 중간의 '요'(중요, 필요)는 건드리면 안 된다.
_TRAILING_YO = re.compile(r"([가-힣])요(\s*[.!?…,~]*)\s*$")
_TRAILING_YO_MID = re.compile(r"([가-힣])요(\s*[.!?…,~]+\s+)")
def _split_bnida(text: str) -> str:
"""'갑니다' 처럼 어간에 ㅂ 이 합쳐진 형태를 푼다. 갑니다 -> 가 + 어."""
def _sub(match: re.Match[str]) -> str:
syllable = match.group(1)
parts = _decompose(syllable)
if parts is None:
return match.group(0)
cho, jung, jong = parts
if jong != 17: # ㅂ 받침이 아니면 대상이 아니다
return match.group(0)
return _attach_eo(_compose(cho, jung, 0))
return re.sub(r"([가-힣])니다", _sub, text)
def to_casual(text: str) -> str:
"""한국어 존댓말 문장을 반말로 낮춘다.
번역 모델이 내놓는 격식체 어미를 대상으로 한 규칙 기반 변환이라
100%는 아니다. 다만 게임 자막에서 실제로 나오는 어미는 종류가
많지 않고, 활용은 실제 문법 규칙으로 처리하므로 대부분을 덮는다.
"""
if not text:
return text
out = text
for source, target in _CONTRACTED.items():
out = out.replace(source, target)
out = _GESSEUMNIDA.sub(lambda m: _attach_lge(m.group(1)), out)
out = _SIPSIO.sub(lambda m: _attach_eo(m.group(1)), out)
for pattern, target in _CASUAL_RULES:
out = pattern.sub(target, out)
out = _SEUMNIKKA.sub(lambda m: _attach_eo(m.group(1)) + "?", out)
out = _SEUMNIDA.sub(lambda m: _attach_eo(m.group(1)), out)
out = _SEYO.sub(lambda m: _attach_eo(m.group(1)), out)
out = _split_bnida(out)
# 문장 끝 '요' 떼기 — 문장 중간의 '중요/필요'가 다치면 안 된다.
out = _TRAILING_YO_MID.sub(r"\1\2", out)
out = _TRAILING_YO.sub(r"\1\2", out)
return out.strip()
def apply_speech_level(
translated: str,
target_lang: str,
level: SpeechLevel,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""번역 결과에 말투 설정을 적용한다.
존댓말 모드는 아무것도 하지 않는다. 번역 모델의 한국어 출력이 이미
격식체이고, 한국어에서 존댓말은 어떤 상황에서도 틀린 선택이 아니다.
"""
if target_lang != "ko" or level is not SpeechLevel.CASUAL:
return translated
# 사용자 규칙: 원문이 진짜 존댓말이면 반말 모드여도 존댓말을 지킨다.
if source_politeness is Politeness.POLITE:
return translated
return to_casual(translated)
def prompt_instruction(level: SpeechLevel, source_politeness: Politeness) -> str:
"""지시문을 이해하는 LLM(Qwen3 등)에 넣을 말투 지시.
Seed-X 처럼 지시문을 못 알아듣는 번역 전용 모델에는 쓰지 않는다.
그쪽은 `apply_speech_level` 후처리로만 말투를 맞춘다.
"""
if source_politeness is Politeness.POLITE:
return "The speaker is being polite, so use Korean polite speech (존댓말)."
if level is SpeechLevel.CASUAL:
return "Use casual Korean speech (반말), the way friends talk while gaming."
return "Use polite Korean speech (존댓말)."
# --- 조사 교정 --------------------------------------------------------------
#: 앞말의 받침 유무로 갈리는 조사 쌍 (받침 있음, 받침 없음)
_PARTICLE_PAIRS = [("을", "를"), ("이", "가"), ("은", "는"), ("과", "와"), ("으로", "로")]
_PARTICLE_RE = re.compile(
r"([가-힣])(" + "|".join(sorted(
{p for pair in _PARTICLE_PAIRS for p in pair}, key=len, reverse=True
)) + r")(?=\s|$|[.!?,…~])"
)
_PARTICLE_LOOKUP = {}
for _with, _without in _PARTICLE_PAIRS:
_PARTICLE_LOOKUP[_with] = (_with, _without)
_PARTICLE_LOOKUP[_without] = (_with, _without)
def fix_particles(text: str) -> str:
"""앞말 받침에 맞게 조사를 고친다. 자기장를 -> 자기장을.
용어집이 단어를 통째로 바꿔치기하기 때문에 생기는 문제다. 번역 모델은
자리표시자를 기준으로 조사를 골랐는데, 복원된 역어의 받침이 그와 다르면
"자기장를" 같은 어긋남이 남는다. 받침은 한글 코드에서 바로 읽을 수 있어
규칙만으로 정확히 고칠 수 있다.
"""
if not text:
return text
def _sub(match: re.Match[str]) -> str:
head, particle = match.group(1), match.group(2)
pair = _PARTICLE_LOOKUP.get(particle)
if pair is None:
return match.group(0)
parts = _decompose(head)
if parts is None:
return match.group(0)
has_batchim = parts[2] != 0
# '으로/로' 는 ㄹ 받침이면 '로'를 쓴다 (서울로, 칼로).
if pair == ("으로", "로") and parts[2] == _RIEUL:
return head + "로"
return head + (pair[0] if has_batchim else pair[1])
return _PARTICLE_RE.sub(_sub, text)

209
src/livesub/models/tiers.py Normal file
View File

@@ -0,0 +1,209 @@
"""모델 품질 티어 정의.
사용자에게는 실제 모델 이름 대신 5단계 티어(속도우선 → 품질우선)로 노출한다.
각 티어는 ASR(음성인식) 1개 + MT(번역) 1개의 조합이다.
선정 근거는 docs/MODELS.md 참고.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from ..constants import DEFAULT_TIER
class MTBackend(str, Enum):
"""번역 모델 실행 백엔드."""
CTRANSLATE2 = "ctranslate2" # NLLB 계열 seq2seq
TRANSFORMERS = "transformers" # LLM 계열 (Seed-X / Qwen3)
class PromptStyle(str, Enum):
"""LLM 백엔드의 프롬프트 형식. 모델 계열마다 요구사항이 다르다."""
#: 해당 없음 (seq2seq)
NONE = "none"
#: Seed-X 전용. chat template 이 없는 번역 전용 completion 모델이라
#: 모델 카드에 적힌 고정 형식 + 끝의 `<언어코드>` 태그를 그대로 지켜야 한다.
SEEDX = "seedx"
#: Qwen3 등 일반 instruct 모델. chat template + 자유 지시문 사용 가능.
INSTRUCT = "instruct"
@dataclass(frozen=True)
class AsrSpec:
repo: str
compute_type: str
beam_size: int = 1
vram_mb: int = 0
@dataclass(frozen=True)
class MTSpec:
repo: str
backend: MTBackend
compute_type: str = "float16"
vram_mb: int = 0
prompt_style: PromptStyle = PromptStyle.NONE
#: LoRA 추가학습 난이도 1(쉬움) ~ 3(까다로움)
finetune_ease: int = 2
@property
def supports_prompt_glossary(self) -> bool:
"""프롬프트로 용어집을 지시할 수 있는가.
지시문을 이해하는 instruct 모델만 가능하다. Seed-X 는 번역만 하도록
학습된 completion 모델이라 "이 용어는 이렇게 옮겨라" 같은 문장을
넣으면 학습 분포를 벗어나 오히려 번역이 망가진다. seq2seq 도 마찬가지다.
이 둘은 플레이스홀더 치환 방식으로 용어를 보호한다.
"""
return self.prompt_style is PromptStyle.INSTRUCT
@dataclass(frozen=True)
class Tier:
key: str
order: int
name: str
tagline: str
asr: AsrSpec
mt: MTSpec
approx_latency_s: float
min_vram_gb: float
notes: str = ""
recommended: bool = False
best_after_finetune: bool = False
@property
def total_vram_mb(self) -> int:
return self.asr.vram_mb + self.mt.vram_mb
@property
def download_gb(self) -> float:
return round(self.total_vram_mb / 1024 * 1.25, 1)
TIERS: dict[str, Tier] = {}
def _register(tier: Tier) -> Tier:
TIERS[tier.key] = tier
return tier
LIGHTNING = _register(
Tier(
key="lightning",
order=1,
name="번개",
tagline="속도 최우선 · 저사양 GPU",
asr=AsrSpec("Systran/faster-whisper-small", "int8_float16", 1, 700),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"int8_float16",
800,
finetune_ease=1,
),
approx_latency_s=0.6,
min_vram_gb=2,
notes="짧은 대사·게임 음성에 충분. 긴 문장이나 전문용어는 품질이 떨어질 수 있습니다.",
)
)
SWIFT = _register(
Tier(
key="swift",
order=2,
name="신속",
tagline="빠르면서 인식률은 최상급",
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "int8_float16", 1, 1600),
mt=MTSpec(
"entai2965/nllb-200-distilled-600M-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
1400,
finetune_ease=1,
),
approx_latency_s=0.9,
min_vram_gb=4,
notes="음성인식을 최상급으로 올리고 번역만 경량으로 유지한 구성. 가성비가 가장 좋습니다.",
)
)
BALANCE = _register(
Tier(
key="balance",
order=3,
name="균형",
tagline="기본값 · 속도와 품질의 중간",
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"entai2965/nllb-200-distilled-1.3B-ctranslate2",
MTBackend.CTRANSLATE2,
"float16",
2900,
finetune_ease=2,
),
approx_latency_s=1.2,
min_vram_gb=6,
notes="8GB VRAM 환경에서 가장 무난한 선택.",
)
)
PRECISION = _register(
Tier(
key="precision",
order=4,
name="정밀",
tagline="추천 · 현 시점 최고 번역 품질",
asr=AsrSpec("dropbox-dash/faster-whisper-large-v3-turbo", "float16", 2, 2400),
mt=MTSpec(
"ByteDance-Seed/Seed-X-PPO-7B-AWQ-Int4",
MTBackend.TRANSFORMERS,
"int4",
5600,
prompt_style=PromptStyle.SEEDX,
finetune_ease=3,
),
approx_latency_s=2.0,
min_vram_gb=10,
notes="번역 전용으로 학습된 7B 모델. 한/영/일/중 구어체와 문맥 처리가 확연히 좋습니다.",
recommended=True,
)
)
ULTIMATE = _register(
Tier(
key="ultimate",
order=5,
name="극한",
tagline="품질 최우선 · 추가학습(LoRA)에 최적",
asr=AsrSpec("Systran/faster-whisper-large-v3", "float16", 5, 3100),
mt=MTSpec(
"Qwen/Qwen3-8B",
MTBackend.TRANSFORMERS,
"float16",
11000,
prompt_style=PromptStyle.INSTRUCT,
finetune_ease=1,
),
approx_latency_s=3.0,
min_vram_gb=16,
notes="게임·방송 용어를 LoRA로 추가학습해 붙일 때 가장 잘 먹히는 구성입니다.",
best_after_finetune=True,
)
)
assert DEFAULT_TIER in TIERS, f"constants.DEFAULT_TIER({DEFAULT_TIER}) 가 티어 목록에 없습니다"
def ordered_tiers() -> list[Tier]:
return sorted(TIERS.values(), key=lambda t: t.order)
def get_tier(key: str) -> Tier:
return TIERS.get(key, TIERS[DEFAULT_TIER])

View File

@@ -0,0 +1,376 @@
"""번역(MT) 래퍼.
두 백엔드를 지원한다.
* CTranslate2 — NLLB 계열 seq2seq. 가볍고 빠르며 추가학습이 쉽다.
* Transformers — Seed-X / Qwen3 같은 LLM. 품질이 좋고 LoRA 어댑터를 얹을 수 있다.
LLM 백엔드는 다시 두 가지 프롬프트 형식으로 갈린다. 같은 "LLM"이라도 요구사항이
정반대라 하나로 합치면 한쪽이 반드시 망가진다.
* PromptStyle.SEEDX — Seed-X 는 chat template 이 없는 **번역 전용 completion 모델**이다.
모델 카드가 정한 고정 문장과 끝의 `<언어코드>` 태그를 그대로 지켜야 하고,
그 밖의 지시문을 끼워 넣으면 학습 분포를 벗어난다. 따라서 용어집은 프롬프트가
아니라 **플레이스홀더 치환**으로 넣는다.
* PromptStyle.INSTRUCT — Qwen3 등. chat template 을 쓰고 용어집을 지시문으로 넣는다.
세 경우 모두 `Translator` 인터페이스를 따르므로 상위 레이어는 구분하지 않는다.
"""
from __future__ import annotations
import abc
import logging
import re
import threading
from ..constants import LANGUAGES, models_dir
from .glossary import Glossary
from .speech_level import Politeness, SpeechLevel, fix_particles, prompt_instruction
from .tiers import MTBackend, MTSpec, PromptStyle
log = logging.getLogger(__name__)
#: 문장 끝에 LLM이 붙이곤 하는 군더더기를 걷어내기 위한 패턴
_TRAILING_NOISE = re.compile(r"^\s*(?:번역[::]|Translation[::])\s*", re.IGNORECASE)
def _restore_or_retranslate(out: str, replacements: list[str], retry) -> str:
"""자리표시자를 역어로 되돌린다. 하나라도 사라졌으면 용어집 없이 다시 번역.
번역 모델이 문장 일부를 통째로 누락하면 그 안의 자리표시자도 같이
사라진다. 그대로 복원하면 용어가 자막에서 증발하는데, 그건 용어집을
아예 안 쓴 것보다 나쁘다. 그래서 그럴 때는 보호 없이 한 번 더 번역해
원문 내용이 살아 있는 결과를 쓴다.
"""
missing = Glossary.missing_placeholders(out, replacements)
if not missing:
return Glossary.restore(out, replacements)
log.debug("자리표시자 %s 소실 — 용어집 없이 재번역", missing)
return retry()
def build_seedx_prompt(text: str, source_lang: str, target_lang: str) -> str:
"""Seed-X 모델 카드가 요구하는 정확한 프롬프트.
Translate the following English sentence into Chinese:
May the force be with you <zh>
끝의 `<언어코드>` 태그는 PPO 학습에 쓰인 것이라 **필수**다. 빠지면 번역
품질이 크게 흔들린다. 형식을 바꾸면 안 되므로 지시문이나 용어집 설명을
여기 끼워 넣지 말 것.
출처: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B
"""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
tag = LANGUAGES[target_lang]["seedx"]
return f"Translate the following {src} sentence into {tgt}:\n{text} <{tag}>"
class Translator(abc.ABC):
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
self.spec = spec
self.device = device
self.device_index = device_index
self._lock = threading.Lock()
@property
@abc.abstractmethod
def loaded(self) -> bool: ...
@abc.abstractmethod
def load(self) -> None: ...
@abc.abstractmethod
def unload(self) -> None: ...
@abc.abstractmethod
def _translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None,
speech_level: SpeechLevel,
source_politeness: Politeness,
) -> str: ...
def translate(
self,
text: str,
source_lang: str,
target_lang: str,
glossary: Glossary | None = None,
speech_level: SpeechLevel = SpeechLevel.POLITE,
source_politeness: Politeness = Politeness.UNKNOWN,
) -> str:
"""말투(speech_level)는 지시문을 이해하는 모델에만 프롬프트로 전달된다.
나머지 백엔드는 상위에서 `apply_speech_level` 후처리로 맞춘다.
"""
text = text.strip()
if not text:
return ""
if source_lang == target_lang:
return text
out = self._translate(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
out = _TRAILING_NOISE.sub("", out).strip()
if target_lang == "ko":
# 용어집이 단어를 바꿔치기하면 앞말 받침이 달라져 조사가 어긋난다.
# (자기장를 -> 자기장을)
out = fix_particles(out)
return out
class CTranslate2Translator(Translator):
"""NLLB 계열. 용어집은 플레이스홀더 보호 방식으로 적용한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0) -> None:
super().__init__(spec, device, device_index)
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import ctranslate2
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
local = snapshot_download(self.spec.repo, cache_dir=str(models_dir()))
compute = self.spec.compute_type
if self.device == "cpu" and "float16" in compute:
compute = "int8"
log.info("번역 모델 로드: %s (%s/%s)", self.spec.repo, self.device, compute)
self._model = ctranslate2.Translator(
local,
device=self.device,
device_index=self.device_index,
compute_type=compute,
)
self._tokenizer = AutoTokenizer.from_pretrained(local, cache_dir=str(models_dir()))
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
def _run_model(self, text: str, source_lang: str, target_lang: str) -> str:
"""용어집 처리를 뺀 순수 번역 한 번."""
src_code = LANGUAGES[source_lang]["nllb"]
tgt_code = LANGUAGES[target_lang]["nllb"]
with self._lock:
self._tokenizer.src_lang = src_code
tokens = self._tokenizer.convert_ids_to_tokens(
self._tokenizer.encode(text, truncation=True, max_length=512)
)
results = self._model.translate_batch(
[tokens],
target_prefix=[[tgt_code]],
beam_size=2,
max_decoding_length=512,
repetition_penalty=1.1,
)
hyp = results[0].hypotheses[0]
if hyp and hyp[0] == tgt_code:
hyp = hyp[1:]
return self._tokenizer.decode(
self._tokenizer.convert_tokens_to_ids(hyp), skip_special_tokens=True
)
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
# seq2seq 는 프롬프트가 없어 말투를 지시할 수 없다. 후처리로 맞춘다.
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
if glossary is None or not len(glossary):
return self._run_model(text, source_lang, target_lang)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return self._run_model(text, source_lang, target_lang)
out = self._run_model(protected, source_lang, target_lang)
return _restore_or_retranslate(
out, replacements,
lambda: self._run_model(text, source_lang, target_lang),
)
class LLMTranslator(Translator):
"""Seed-X / Qwen3 등 LLM 백엔드. 용어집은 프롬프트로 직접 지시한다."""
def __init__(self, spec: MTSpec, device: str = "cuda", device_index: int = 0,
lora_path: str = "") -> None:
super().__init__(spec, device, device_index)
self.lora_path = lora_path
self._model = None
self._tokenizer = None
@property
def loaded(self) -> bool:
return self._model is not None
def load(self) -> None:
if self._model is not None:
return
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
dtype = torch.float16 if self.device == "cuda" else torch.float32
log.info("번역 LLM 로드: %s (%s)", self.spec.repo, self.device)
self._tokenizer = AutoTokenizer.from_pretrained(
self.spec.repo, cache_dir=str(models_dir())
)
try:
self._model = AutoModelForCausalLM.from_pretrained(
self.spec.repo,
cache_dir=str(models_dir()),
torch_dtype=dtype,
device_map={"": self.device_index} if self.device == "cuda" else "cpu",
)
except (ImportError, RuntimeError, ValueError) as exc:
if self.spec.compute_type == "int4":
# AWQ 가중치는 전용 커널이 있어야 읽힌다. 메시지가 모호해서
# 사용자가 원인을 못 찾는 일이 잦으므로 해결 방법을 같이 알려준다.
raise RuntimeError(
f"양자화 번역 모델({self.spec.repo})을 불러오지 못했습니다.\n"
"AWQ 커널이 필요합니다: pip install autoawq\n"
"설치가 어려우면 '모델' 화면에서 다른 티어를 선택하세요.\n"
f"원인: {exc}"
) from exc
raise
if self.lora_path:
from peft import PeftModel
log.info("LoRA 어댑터 적용: %s", self.lora_path)
self._model = PeftModel.from_pretrained(self._model, self.lora_path)
self._model.eval()
def unload(self) -> None:
with self._lock:
self._model = None
self._tokenizer = None
try:
import torch
torch.cuda.empty_cache()
except Exception: # noqa: BLE001
pass
def _build_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
if self.spec.prompt_style is PromptStyle.SEEDX:
# Seed-X 는 지시문을 못 알아듣는다. 말투는 후처리로만 맞춘다.
return build_seedx_prompt(text, source_lang, target_lang)
return self._build_instruct_prompt(
text, source_lang, target_lang, glossary, speech_level, source_politeness
)
def _build_instruct_prompt(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE,
source_politeness=Politeness.UNKNOWN) -> str:
"""지시문을 이해하는 모델용. 가능하면 chat template 을 태운다."""
src = LANGUAGES[source_lang]["english"]
tgt = LANGUAGES[target_lang]["english"]
hint = glossary.prompt_hint(text, target_lang) if glossary else ""
parts = [
f"Translate the following {src} text into {tgt}. "
"It is a live spoken line from a game or broadcast, so keep the tone "
"casual and natural. Output only the translation."
]
if target_lang == "ko":
parts.append(prompt_instruction(speech_level, source_politeness))
if hint:
parts.append(hint)
parts.append(f"{src}: {text}\n{tgt}:")
instruction = "\n\n".join(parts)
template = getattr(self._tokenizer, "chat_template", None)
if not template:
return instruction
# Qwen3 는 기본적으로 thinking 모드가 켜져 실시간 자막에는 너무 느리다.
try:
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
except TypeError:
# enable_thinking 을 모르는 템플릿
return self._tokenizer.apply_chat_template(
[{"role": "user", "content": instruction}],
tokenize=False,
add_generation_prompt=True,
)
def _generate(self, prompt: str) -> str:
"""프롬프트 하나를 돌려 첫 줄만 돌려준다."""
import torch
with self._lock:
inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
with torch.inference_mode():
generated = self._model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
num_beams=1,
repetition_penalty=1.05,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = generated[0][inputs["input_ids"].shape[-1] :]
out = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
return out.strip().split("\n")[0]
def _translate(self, text, source_lang, target_lang, glossary,
speech_level=SpeechLevel.POLITE, source_politeness=Politeness.UNKNOWN):
if self._model is None:
self.load()
assert self._model is not None and self._tokenizer is not None
def run(source: str, gloss) -> str:
return self._generate(
self._build_prompt(
source, source_lang, target_lang, gloss,
speech_level, source_politeness,
)
)
# Seed-X 는 지시문을 못 알아들으므로 용어집을 자리표시자로 보호한다.
# 지시문을 이해하는 모델은 프롬프트로 넣으므로 보호가 필요 없다.
needs_placeholder = (
self.spec.prompt_style is PromptStyle.SEEDX
and glossary is not None
and len(glossary)
)
if not needs_placeholder:
return run(text, glossary)
protected, replacements = glossary.protect(text, target_lang)
if not replacements:
return run(text, glossary)
out = run(protected, None)
return _restore_or_retranslate(
out, replacements, lambda: run(text, None)
)
def create_translator(
spec: MTSpec, device: str = "cuda", device_index: int = 0, lora_path: str = ""
) -> Translator:
if spec.backend is MTBackend.CTRANSLATE2:
return CTranslate2Translator(spec, device, device_index)
return LLMTranslator(spec, device, device_index, lora_path)

View File

@@ -0,0 +1,374 @@
{
"key": "apex",
"name": "에이펙스 레전드",
"description": "링·배너·부활 등 스쿼드 콜. 배틀로얄 FPS를 같이 하신다면 켜두세요.",
"scope": "game",
"entries": [
{
"source": "legend",
"targets": {
"ko": "레전드",
"ja": "レジェンド",
"zh": "传奇"
}
},
{
"source": "ring",
"targets": {
"ko": "링",
"ja": "リング",
"zh": "圆圈"
}
},
{
"source": "ring is closing",
"targets": {
"ko": "링 좁혀진다",
"ja": "リングが縮む",
"zh": "圈要缩了"
}
},
{
"source": "jumpmaster",
"targets": {
"ko": "점프마스터",
"ja": "ジャンプマスター",
"zh": "跳伞指挥"
}
},
{
"source": "hot zone",
"targets": {
"ko": "핫존",
"ja": "ホットゾーン",
"zh": "热区"
}
},
{
"source": "care package",
"targets": {
"ko": "보급",
"ja": "ケアパッケージ",
"zh": "空投"
}
},
{
"source": "banner",
"targets": {
"ko": "배너",
"ja": "バナー",
"zh": "旗帜"
}
},
{
"source": "grab my banner",
"targets": {
"ko": "내 배너 챙겨",
"ja": "バナー取って",
"zh": "捡我旗帜"
},
"whole_only": true
},
{
"source": "respawn beacon",
"targets": {
"ko": "리스폰 비컨",
"ja": "リスポーンビーコン",
"zh": "重生信标"
}
},
{
"source": "survey beacon",
"targets": {
"ko": "조사 비컨",
"ja": "調査ビーコン",
"zh": "勘探信标"
}
},
{
"source": "knocked",
"targets": {
"ko": "눕혔다",
"ja": "ダウンさせた",
"zh": "打倒了"
},
"whole_only": true
},
{
"source": "thirsting",
"targets": {
"ko": "마무리",
"ja": "追い打ち",
"zh": "补刀"
}
},
{
"source": "finisher",
"targets": {
"ko": "피니셔",
"ja": "フィニッシャー",
"zh": "处决"
},
"whole_only": true
},
{
"source": "knockdown shield",
"targets": {
"ko": "녹다운 실드",
"ja": "ノックダウンシールド",
"zh": "击倒护盾"
}
},
{
"source": "evo shield",
"targets": {
"ko": "진화 실드",
"ja": "EVOシールド",
"zh": "进化护盾"
}
},
{
"source": "shield cell",
"targets": {
"ko": "실드 셀",
"ja": "シールドセル",
"zh": "小蓝"
}
},
{
"source": "shield battery",
"targets": {
"ko": "실드 배터리",
"ja": "シールドバッテリー",
"zh": "大蓝"
}
},
{
"source": "med kit",
"targets": {
"ko": "의료 키트",
"ja": "医療キット",
"zh": "大血包"
}
},
{
"source": "syringe",
"targets": {
"ko": "주사기",
"ja": "注射器",
"zh": "小血包"
}
},
{
"source": "phoenix kit",
"targets": {
"ko": "피닉스 키트",
"ja": "フェニックスキット",
"zh": "凤凰治疗包"
}
},
{
"source": "heat shield",
"targets": {
"ko": "히트 실드",
"ja": "ヒートシールド",
"zh": "隔热罩"
}
},
{
"source": "ult",
"targets": {
"ko": "궁",
"ja": "アルト",
"zh": "大招"
}
},
{
"source": "ultimate accelerant",
"targets": {
"ko": "궁 가속제",
"ja": "アルト促進剤",
"zh": "大招加速器"
}
},
{
"source": "tactical",
"targets": {
"ko": "전술 능력",
"ja": "戦術アビリティ",
"zh": "战术技能"
}
},
{
"source": "passive",
"targets": {
"ko": "패시브",
"ja": "パッシブ",
"zh": "被动"
}
},
{
"source": "zipline",
"targets": {
"ko": "집라인",
"ja": "ジップライン",
"zh": "索道"
}
},
{
"source": "portal",
"targets": {
"ko": "포탈",
"ja": "ポータル",
"zh": "传送门"
}
},
{
"source": "dome",
"targets": {
"ko": "돔",
"ja": "ドーム",
"zh": "护盾罩"
}
},
{
"source": "gas",
"targets": {
"ko": "가스",
"ja": "ガス",
"zh": "毒气"
}
},
{
"source": "wall",
"targets": {
"ko": "벽",
"ja": "ウォール",
"zh": "墙"
}
},
{
"source": "attachment",
"targets": {
"ko": "부착물",
"ja": "アタッチメント",
"zh": "配件"
}
},
{
"source": "purple",
"targets": {
"ko": "보라",
"ja": "紫",
"zh": "紫色"
}
},
{
"source": "gold",
"targets": {
"ko": "금",
"ja": "金",
"zh": "金色"
}
},
{
"source": "loot bin",
"targets": {
"ko": "보급 상자",
"ja": "ルートビン",
"zh": "物资箱"
}
},
{
"source": "death box",
"targets": {
"ko": "데스박스",
"ja": "デスボックス",
"zh": "死亡盒"
}
},
{
"source": "third party",
"targets": {
"ko": "어부",
"ja": "漁夫",
"zh": "第三方"
}
},
{
"source": "rotate",
"targets": {
"ko": "로테",
"ja": "ローテ",
"zh": "转点"
}
},
{
"source": "push them",
"targets": {
"ko": "밀어",
"ja": "詰めよう",
"zh": "推他们"
},
"whole_only": true
},
{
"source": "back off",
"targets": {
"ko": "빠져",
"ja": "引こう",
"zh": "撤"
},
"whole_only": true
},
{
"source": "high ground",
"targets": {
"ko": "고지",
"ja": "高所",
"zh": "高地"
}
},
{
"source": "ping",
"targets": {
"ko": "핑",
"ja": "ピン",
"zh": "标记"
}
},
{
"source": "ping it",
"targets": {
"ko": "핑 찍어",
"ja": "ピンして",
"zh": "标记一下"
},
"whole_only": true
},
{
"source": "squad wiped",
"targets": {
"ko": "스쿼드 전멸",
"ja": "スクワッド全滅",
"zh": "小队团灭"
}
},
{
"source": "champion",
"targets": {
"ko": "챔피언",
"ja": "チャンピオン",
"zh": "冠军"
}
},
{
"source": "kill leader",
"targets": {
"ko": "킬 리더",
"ja": "キルリーダー",
"zh": "击杀王"
}
}
]
}

View File

@@ -0,0 +1,385 @@
{
"key": "fps-common",
"name": "FPS 공통",
"description": "장르 전반에서 두루 쓰는 총기·교전·파티 콜. 게임별 팩과 같이 켜세요.",
"scope": "generic",
"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": "掩护我"
},
"whole_only": true
},
{
"source": "covering",
"targets": {
"ko": "엄호 중",
"ja": "援護中",
"zh": "掩护中"
}
},
{
"source": "push",
"targets": {
"ko": "밀자",
"ja": "押そう",
"zh": "推进"
},
"whole_only": true
},
{
"source": "fall back",
"targets": {
"ko": "후퇴",
"ja": "後退",
"zh": "撤退"
},
"whole_only": true
},
{
"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": "救我"
},
"whole_only": true
},
{
"source": "heal up",
"targets": {
"ko": "회복하자",
"ja": "回復しよう",
"zh": "回血"
},
"whole_only": true
},
{
"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": "背后有人"
},
"whole_only": true
},
{
"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": "打得好"
},
"whole_only": true
},
{
"source": "nice shot",
"targets": {
"ko": "잘 쐈다",
"ja": "ナイスショット",
"zh": "好枪"
},
"whole_only": true
},
{
"source": "my bad",
"targets": {
"ko": "내 실수",
"ja": "ごめん",
"zh": "我的错"
},
"whole_only": true
}
]
}

View File

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

View File

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

View File

@@ -0,0 +1,405 @@
{
"key": "overwatch",
"name": "오버워치 2",
"description": "역할·궁·거점 콜. 한국 공식 번역어를 우선했습니다.",
"scope": "game",
"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": "集合"
},
"whole_only": true
},
{
"source": "group up",
"targets": {
"ko": "모이자",
"ja": "集合",
"zh": "集合"
},
"whole_only": true
},
{
"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": "背后有人"
},
"whole_only": true
},
{
"source": "falling back",
"targets": {
"ko": "후퇴한다",
"ja": "後退する",
"zh": "撤退"
},
"whole_only": true
},
{
"source": "push",
"targets": {
"ko": "밀자",
"ja": "押そう",
"zh": "推进"
},
"whole_only": true
}
]
}

View File

@@ -0,0 +1,538 @@
{
"key": "pubg",
"name": "배틀그라운드 (PUBG)",
"description": "자기장·파밍·스쿼드 콜. 배틀로얄 공통 용어도 같이 들어 있습니다.",
"scope": "game",
"entries": [
{
"source": "chicken dinner",
"targets": {
"ko": "치킨",
"ja": "ドン勝",
"zh": "吃鸡"
}
},
{
"source": "winner winner chicken dinner",
"targets": {
"ko": "이겼닭! 오늘 저녁은 치킨이닭!",
"ja": "ドン勝つ!",
"zh": "大吉大利,今晚吃鸡"
},
"whole_only": true
},
{
"source": "blue zone",
"targets": {
"ko": "자기장",
"ja": "ブルーゾーン",
"zh": "蓝圈"
}
},
{
"source": "circle",
"targets": {
"ko": "자기장",
"ja": "サークル",
"zh": "圈"
}
},
{
"source": "red zone",
"targets": {
"ko": "폭격 지역",
"ja": "レッドゾーン",
"zh": "红区"
}
},
{
"source": "safe zone",
"targets": {
"ko": "안전 구역",
"ja": "セーフゾーン",
"zh": "安全区"
}
},
{
"source": "zone is closing",
"targets": {
"ko": "자기장 좁혀진다",
"ja": "ゾーンが縮まる",
"zh": "圈要缩了"
}
},
{
"source": "final circle",
"targets": {
"ko": "막자",
"ja": "最終サークル",
"zh": "决赛圈"
}
},
{
"source": "drop",
"targets": {
"ko": "낙하",
"ja": "降下",
"zh": "跳伞"
}
},
{
"source": "hot drop",
"targets": {
"ko": "핫드랍",
"ja": "ホットドロップ",
"zh": "热门点"
}
},
{
"source": "landing",
"targets": {
"ko": "착지",
"ja": "着地",
"zh": "落地"
}
},
{
"source": "where are we dropping",
"targets": {
"ko": "어디 내릴까",
"ja": "どこ降りる",
"zh": "跳哪里"
},
"whole_only": true
},
{
"source": "loot",
"targets": {
"ko": "파밍",
"ja": "物資",
"zh": "物资"
}
},
{
"source": "looting",
"targets": {
"ko": "파밍 중",
"ja": "漁り中",
"zh": "搜物资"
}
},
{
"source": "care package",
"targets": {
"ko": "보급",
"ja": "補給物資",
"zh": "空投"
}
},
{
"source": "airdrop",
"targets": {
"ko": "보급 상자",
"ja": "エアドロップ",
"zh": "空投"
}
},
{
"source": "third party",
"targets": {
"ko": "어부",
"ja": "漁夫",
"zh": "第三方"
}
},
{
"source": "third partied",
"targets": {
"ko": "어부 당했다",
"ja": "漁夫られた",
"zh": "被第三方了"
},
"whole_only": true
},
{
"source": "squad",
"targets": {
"ko": "스쿼드",
"ja": "スクワッド",
"zh": "四排"
}
},
{
"source": "duo",
"targets": {
"ko": "듀오",
"ja": "デュオ",
"zh": "双排"
}
},
{
"source": "solo",
"targets": {
"ko": "솔로",
"ja": "ソロ",
"zh": "单排"
}
},
{
"source": "knocked",
"targets": {
"ko": "눕혔다",
"ja": "ダウンさせた",
"zh": "打倒了"
},
"whole_only": true
},
{
"source": "knocked down",
"targets": {
"ko": "기절",
"ja": "ダウン",
"zh": "倒地"
}
},
{
"source": "finish him",
"targets": {
"ko": "마무리해",
"ja": "とどめ刺して",
"zh": "补掉他"
},
"whole_only": true
},
{
"source": "revive",
"targets": {
"ko": "살리기",
"ja": "蘇生",
"zh": "救人"
}
},
{
"source": "reviving",
"targets": {
"ko": "살리는 중",
"ja": "蘇生中",
"zh": "救人中"
}
},
{
"source": "res me",
"targets": {
"ko": "살려줘",
"ja": "起こして",
"zh": "救我"
},
"whole_only": true
},
{
"source": "med kit",
"targets": {
"ko": "구급상자",
"ja": "救急箱",
"zh": "医疗箱"
}
},
{
"source": "first aid",
"targets": {
"ko": "구급상자",
"ja": "応急手当",
"zh": "急救包"
}
},
{
"source": "bandage",
"targets": {
"ko": "붕대",
"ja": "包帯",
"zh": "绷带"
}
},
{
"source": "energy drink",
"targets": {
"ko": "에너지 드링크",
"ja": "エナドリ",
"zh": "饮料"
}
},
{
"source": "painkiller",
"targets": {
"ko": "진통제",
"ja": "鎮痛剤",
"zh": "止痛药"
}
},
{
"source": "boost",
"targets": {
"ko": "부스트",
"ja": "ブースト",
"zh": "能量"
}
},
{
"source": "level 3 helmet",
"targets": {
"ko": "3렙 헬멧",
"ja": "レベル3ヘルメット",
"zh": "三级头"
}
},
{
"source": "level 3 vest",
"targets": {
"ko": "3렙 조끼",
"ja": "レベル3アーマー",
"zh": "三级甲"
}
},
{
"source": "backpack",
"targets": {
"ko": "가방",
"ja": "バックパック",
"zh": "背包"
}
},
{
"source": "scope",
"targets": {
"ko": "배율",
"ja": "スコープ",
"zh": "倍镜"
}
},
{
"source": "8x",
"targets": {
"ko": "8배율",
"ja": "8倍",
"zh": "八倍镜"
}
},
{
"source": "4x",
"targets": {
"ko": "4배율",
"ja": "4倍",
"zh": "四倍镜"
}
},
{
"source": "red dot",
"targets": {
"ko": "레드닷",
"ja": "レッドドット",
"zh": "红点"
}
},
{
"source": "suppressor",
"targets": {
"ko": "소음기",
"ja": "サプレッサー",
"zh": "消音器"
}
},
{
"source": "compensator",
"targets": {
"ko": "보정기",
"ja": "コンペンセイター",
"zh": "补偿器"
}
},
{
"source": "grip",
"targets": {
"ko": "손잡이",
"ja": "グリップ",
"zh": "握把"
}
},
{
"source": "extended mag",
"targets": {
"ko": "확장 탄창",
"ja": "拡張マガジン",
"zh": "扩容弹匣"
}
},
{
"source": "ammo",
"targets": {
"ko": "탄약",
"ja": "弾薬",
"zh": "子弹"
}
},
{
"source": "need ammo",
"targets": {
"ko": "탄 필요해",
"ja": "弾ちょうだい",
"zh": "要子弹"
},
"whole_only": true
},
{
"source": "frag grenade",
"targets": {
"ko": "수류탄",
"ja": "フラグ",
"zh": "手雷"
}
},
{
"source": "molotov",
"targets": {
"ko": "화염병",
"ja": "モロトフ",
"zh": "燃烧瓶"
}
},
{
"source": "smoke",
"targets": {
"ko": "연막",
"ja": "スモーク",
"zh": "烟雾弹"
}
},
{
"source": "stun",
"targets": {
"ko": "섬광",
"ja": "スタン",
"zh": "闪光"
}
},
{
"source": "prone",
"targets": {
"ko": "엎드려",
"ja": "伏せろ",
"zh": "趴下"
}
},
{
"source": "take cover",
"targets": {
"ko": "엄폐해",
"ja": "隠れて",
"zh": "找掩体"
},
"whole_only": true
},
{
"source": "cover",
"targets": {
"ko": "엄폐물",
"ja": "遮蔽物",
"zh": "掩体"
}
},
{
"source": "compound",
"targets": {
"ko": "건물 단지",
"ja": "建物群",
"zh": "房区"
}
},
{
"source": "ridge",
"targets": {
"ko": "능선",
"ja": "尾根",
"zh": "山脊"
}
},
{
"source": "high ground",
"targets": {
"ko": "고지",
"ja": "高所",
"zh": "高地"
}
},
{
"source": "vehicle",
"targets": {
"ko": "차량",
"ja": "車両",
"zh": "载具"
}
},
{
"source": "get in the car",
"targets": {
"ko": "차 타",
"ja": "車乗って",
"zh": "上车"
},
"whole_only": true
},
{
"source": "fuel",
"targets": {
"ko": "연료",
"ja": "燃料",
"zh": "油"
}
},
{
"source": "footsteps",
"targets": {
"ko": "발소리",
"ja": "足音",
"zh": "脚步声"
}
},
{
"source": "I hear footsteps",
"targets": {
"ko": "발소리 들린다",
"ja": "足音する",
"zh": "听到脚步"
},
"whole_only": true
},
{
"source": "sniper",
"targets": {
"ko": "저격수",
"ja": "スナイパー",
"zh": "狙击手"
}
},
{
"source": "kar98",
"targets": {
"ko": "카구팔",
"ja": "Kar98k",
"zh": "98K"
}
},
{
"source": "spectate",
"targets": {
"ko": "관전",
"ja": "観戦",
"zh": "观战"
}
},
{
"source": "rank push",
"targets": {
"ko": "티어 올리기",
"ja": "ランク上げ",
"zh": "上分"
}
}
]
}

View File

@@ -0,0 +1,551 @@
{
"key": "r6siege",
"name": "레인보우 식스 시즈",
"description": "역할·보강·파밸·콜아웃. 한국 서버에서 실제로 쓰는 말로 맞췄습니다.",
"scope": "game",
"entries": [
{
"source": "operator",
"targets": {
"ko": "오퍼레이터",
"ja": "オペレーター",
"zh": "干员"
}
},
{
"source": "op",
"targets": {
"ko": "오퍼",
"ja": "オペ",
"zh": "干员"
}
},
{
"source": "attacker",
"targets": {
"ko": "공격팀",
"ja": "攻撃側",
"zh": "进攻方"
}
},
{
"source": "defender",
"targets": {
"ko": "수비팀",
"ja": "防衛側",
"zh": "防守方"
}
},
{
"source": "drone",
"targets": {
"ko": "드론",
"ja": "ドローン",
"zh": "无人机"
}
},
{
"source": "droning",
"targets": {
"ko": "드론 정찰",
"ja": "ドローン偵察",
"zh": "无人机侦察"
}
},
{
"source": "prep phase",
"targets": {
"ko": "준비 시간",
"ja": "準備フェーズ",
"zh": "准备阶段"
}
},
{
"source": "roam",
"targets": {
"ko": "로밍",
"ja": "ローミング",
"zh": "游走"
}
},
{
"source": "roamer",
"targets": {
"ko": "로머",
"ja": "ローマー",
"zh": "游走位"
}
},
{
"source": "anchor",
"targets": {
"ko": "앵커",
"ja": "アンカー",
"zh": "守点位"
}
},
{
"source": "anchoring",
"targets": {
"ko": "앵커링",
"ja": "アンカリング",
"zh": "守点"
}
},
{
"source": "site",
"targets": {
"ko": "거점",
"ja": "サイト",
"zh": "据点"
}
},
{
"source": "bomb site",
"targets": {
"ko": "폭탄 거점",
"ja": "爆弾サイト",
"zh": "炸弹点"
}
},
{
"source": "defuser",
"targets": {
"ko": "디퓨저",
"ja": "ディフューザー",
"zh": "拆弹器"
}
},
{
"source": "plant",
"targets": {
"ko": "설치",
"ja": "設置",
"zh": "安装"
}
},
{
"source": "planted",
"targets": {
"ko": "설치됐다",
"ja": "設置された",
"zh": "已安装"
},
"whole_only": true
},
{
"source": "defuse",
"targets": {
"ko": "해체",
"ja": "解除",
"zh": "拆除"
}
},
{
"source": "reinforcement",
"targets": {
"ko": "보강",
"ja": "補強",
"zh": "加固"
}
},
{
"source": "reinforce",
"targets": {
"ko": "보강하다",
"ja": "補強する",
"zh": "加固"
},
"whole_only": true
},
{
"source": "hard breach",
"targets": {
"ko": "하드 브리치",
"ja": "ハードブリーチ",
"zh": "硬破"
}
},
{
"source": "hard breacher",
"targets": {
"ko": "하드 브리처",
"ja": "ハードブリーチャー",
"zh": "硬破手"
}
},
{
"source": "soft breach",
"targets": {
"ko": "소프트 브리치",
"ja": "ソフトブリーチ",
"zh": "软破"
}
},
{
"source": "breach charge",
"targets": {
"ko": "브리치 차지",
"ja": "ブリーチチャージ",
"zh": "破门炸药"
}
},
{
"source": "rotate hole",
"targets": {
"ko": "로테 구멍",
"ja": "ローテ穴",
"zh": "转点洞"
}
},
{
"source": "murder hole",
"targets": {
"ko": "총구멍",
"ja": "マーダーホール",
"zh": "射击孔"
}
},
{
"source": "hatch",
"targets": {
"ko": "해치",
"ja": "ハッチ",
"zh": "天花板洞"
}
},
{
"source": "rappel",
"targets": {
"ko": "레펠",
"ja": "ラペル",
"zh": "速降"
}
},
{
"source": "vault",
"targets": {
"ko": "창넘기",
"ja": "乗り越え",
"zh": "翻窗"
}
},
{
"source": "run out",
"targets": {
"ko": "런아웃",
"ja": "ランアウト",
"zh": "冲出"
},
"whole_only": true
},
{
"source": "spawn peek",
"targets": {
"ko": "스폰킬",
"ja": "スポーンピーク",
"zh": "出生点偷袭"
}
},
{
"source": "gadget",
"targets": {
"ko": "장비",
"ja": "ガジェット",
"zh": "道具"
}
},
{
"source": "utility",
"targets": {
"ko": "유틸",
"ja": "ユーティリティ",
"zh": "道具"
}
},
{
"source": "trap",
"targets": {
"ko": "함정",
"ja": "トラップ",
"zh": "陷阱"
}
},
{
"source": "nitro cell",
"targets": {
"ko": "니트로",
"ja": "ニトロセル",
"zh": "C4"
}
},
{
"source": "impact",
"targets": {
"ko": "임팩트",
"ja": "インパクト",
"zh": "碰撞雷"
}
},
{
"source": "flashbang",
"targets": {
"ko": "섬광탄",
"ja": "フラッシュ",
"zh": "闪光弹"
}
},
{
"source": "smoke grenade",
"targets": {
"ko": "연막탄",
"ja": "スモーク",
"zh": "烟雾弹"
}
},
{
"source": "claymore",
"targets": {
"ko": "클레이모어",
"ja": "クレイモア",
"zh": "阔剑雷"
}
},
{
"source": "barbed wire",
"targets": {
"ko": "철조망",
"ja": "鉄条網",
"zh": "铁丝网"
}
},
{
"source": "deployable shield",
"targets": {
"ko": "방패",
"ja": "展開シールド",
"zh": "可部署盾"
}
},
{
"source": "bulletproof camera",
"targets": {
"ko": "방탄 카메라",
"ja": "防弾カメラ",
"zh": "防弹摄像头"
}
},
{
"source": "cam",
"targets": {
"ko": "카메라",
"ja": "カメラ",
"zh": "摄像头"
}
},
{
"source": "destroy the cam",
"targets": {
"ko": "카메라 깨줘",
"ja": "カメラ壊して",
"zh": "打掉摄像头"
},
"whole_only": true
},
{
"source": "prefire",
"targets": {
"ko": "선샷",
"ja": "プリファイア",
"zh": "预瞄"
}
},
{
"source": "peek",
"targets": {
"ko": "픽",
"ja": "ピーク",
"zh": "架点"
}
},
{
"source": "lean",
"targets": {
"ko": "린",
"ja": "リーン",
"zh": "侧身"
}
},
{
"source": "crouch",
"targets": {
"ko": "앉기",
"ja": "しゃがみ",
"zh": "蹲"
}
},
{
"source": "prone",
"targets": {
"ko": "엎드리기",
"ja": "伏せ",
"zh": "趴下"
}
},
{
"source": "trade",
"targets": {
"ko": "트레이드",
"ja": "トレード",
"zh": "换人"
}
},
{
"source": "entry frag",
"targets": {
"ko": "선취점",
"ja": "エントリーキル",
"zh": "首杀"
}
},
{
"source": "clutch",
"targets": {
"ko": "클러치",
"ja": "クラッチ",
"zh": "残局"
}
},
{
"source": "ace",
"targets": {
"ko": "에이스",
"ja": "エース",
"zh": "一穿五"
}
},
{
"source": "down",
"targets": {
"ko": "다운",
"ja": "ダウン",
"zh": "倒地"
}
},
{
"source": "dbno",
"targets": {
"ko": "기절",
"ja": "ダウン状態",
"zh": "濒死"
}
},
{
"source": "revive",
"targets": {
"ko": "살리기",
"ja": "蘇生",
"zh": "扶起"
}
},
{
"source": "callout",
"targets": {
"ko": "콜",
"ja": "コール",
"zh": "报点"
}
},
{
"source": "rotate",
"targets": {
"ko": "로테",
"ja": "ローテ",
"zh": "转点"
}
},
{
"source": "flank",
"targets": {
"ko": "측면",
"ja": "フランク",
"zh": "绕后"
}
},
{
"source": "watch flank",
"targets": {
"ko": "측면 봐줘",
"ja": "フランク見て",
"zh": "看侧面"
},
"whole_only": true
},
{
"source": "above you",
"targets": {
"ko": "위에 있다",
"ja": "上にいる",
"zh": "在你上面"
},
"whole_only": true
},
{
"source": "below you",
"targets": {
"ko": "아래에 있다",
"ja": "下にいる",
"zh": "在你下面"
},
"whole_only": true
},
{
"source": "basement",
"targets": {
"ko": "지하",
"ja": "地下",
"zh": "地下室"
}
},
{
"source": "top floor",
"targets": {
"ko": "최상층",
"ja": "最上階",
"zh": "顶层"
}
},
{
"source": "outside",
"targets": {
"ko": "바깥",
"ja": "外",
"zh": "外面"
}
},
{
"source": "ranked",
"targets": {
"ko": "랭크",
"ja": "ランク",
"zh": "排位"
}
},
{
"source": "overtime",
"targets": {
"ko": "연장",
"ja": "オーバータイム",
"zh": "加时"
}
},
{
"source": "ban",
"targets": {
"ko": "밴",
"ja": "バン",
"zh": "禁用"
}
}
]
}

View File

@@ -0,0 +1,322 @@
{
"key": "streaming",
"name": "방송·스트리밍",
"description": "해외 방송을 볼 때 자주 나오는 말. 게임 용어집과 같이 켜면 좋습니다.",
"scope": "generic",
"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": "打赏"
},
"whole_only": true
},
{
"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": "送"
},
"whole_only": true
},
{
"source": "griefing",
"targets": {
"ko": "트롤링",
"ja": "荒らし",
"zh": "演员"
}
},
{
"source": "toxic",
"targets": {
"ko": "매너 나쁜",
"ja": "民度が低い",
"zh": "喷子"
}
},
{
"source": "cracked",
"targets": {
"ko": "미쳤다",
"ja": "ヤバい",
"zh": "太强了"
},
"whole_only": true
},
{
"source": "insane",
"targets": {
"ko": "대박",
"ja": "やばい",
"zh": "离谱"
}
},
{
"source": "clutch",
"targets": {
"ko": "클러치",
"ja": "クラッチ",
"zh": "关键时刻"
}
},
{
"source": "let's go",
"targets": {
"ko": "가자",
"ja": "行くぞ",
"zh": "冲"
},
"whole_only": true
},
{
"source": "no way",
"targets": {
"ko": "말도 안 돼",
"ja": "ありえない",
"zh": "不可能吧"
},
"whole_only": true
},
{
"source": "give me a second",
"targets": {
"ko": "잠깐만",
"ja": "ちょっと待って",
"zh": "等一下"
},
"whole_only": true
},
{
"source": "hold on",
"targets": {
"ko": "잠깐",
"ja": "待って",
"zh": "等等"
},
"whole_only": true
},
{
"source": "be right back",
"targets": {
"ko": "금방 올게",
"ja": "すぐ戻る",
"zh": "马上回来"
},
"whole_only": true
},
{
"source": "thanks for the sub",
"targets": {
"ko": "구독 고마워",
"ja": "サブありがとう",
"zh": "谢谢订阅"
},
"whole_only": true
},
{
"source": "welcome back",
"targets": {
"ko": "다시 왔네",
"ja": "おかえり",
"zh": "欢迎回来"
},
"whole_only": true
}
]
}

View File

@@ -0,0 +1,482 @@
{
"key": "valorant",
"name": "발로란트",
"description": "스파이크·유틸·이코노미 콜. 아시아 서버 영어 콜아웃에 맞췄습니다.",
"scope": "game",
"entries": [
{
"source": "spike",
"targets": {
"ko": "스파이크",
"ja": "スパイク",
"zh": "斩杀者"
}
},
{
"source": "plant",
"targets": {
"ko": "설치",
"ja": "設置",
"zh": "安装"
}
},
{
"source": "planted",
"targets": {
"ko": "설치됐다",
"ja": "設置された",
"zh": "已安装"
},
"whole_only": true
},
{
"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": "看到了"
},
"whole_only": true
},
{
"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": "赛点"
}
}
]
}

View File

@@ -0,0 +1,487 @@
{
"key": "watchdogs",
"name": "워치독스 (Watch Dogs)",
"description": "1·2·리전 공통. 조직명과 해킹 용어는 고유명사라 번역기가 특히 잘 틀립니다.",
"scope": "game",
"entries": [
{
"source": "DedSec",
"targets": {
"ko": "데드섹",
"ja": "デッドセック",
"zh": "DedSec"
}
},
{
"source": "ctOS",
"targets": {
"ko": "ctOS",
"ja": "ctOS",
"zh": "ctOS"
}
},
{
"source": "Blume",
"targets": {
"ko": "블룸",
"ja": "ブルーム",
"zh": "布鲁姆"
}
},
{
"source": "Albion",
"targets": {
"ko": "알비온",
"ja": "アルビオン",
"zh": "阿尔比恩"
}
},
{
"source": "Clan Kelley",
"targets": {
"ko": "켈리 클랜",
"ja": "ケリー一家",
"zh": "凯利帮"
}
},
{
"source": "Zero Day",
"targets": {
"ko": "제로 데이",
"ja": "ゼロデイ",
"zh": "零日"
}
},
{
"source": "Bratva",
"targets": {
"ko": "브라트바",
"ja": "ブラトヴァ",
"zh": "兄弟会"
}
},
{
"source": "Viceroys",
"targets": {
"ko": "바이스로이",
"ja": "ヴァイスロイ",
"zh": "总督帮"
}
},
{
"source": "the Fixer",
"targets": {
"ko": "해결사",
"ja": "フィクサー",
"zh": "中间人"
}
},
{
"source": "fixer contract",
"targets": {
"ko": "해결사 의뢰",
"ja": "フィクサー依頼",
"zh": "中间人任务"
}
},
{
"source": "operative",
"targets": {
"ko": "요원",
"ja": "オペレーティブ",
"zh": "特工"
}
},
{
"source": "recruit",
"targets": {
"ko": "영입",
"ja": "勧誘",
"zh": "招募"
}
},
{
"source": "permadeath",
"targets": {
"ko": "영구 사망",
"ja": "パーマデス",
"zh": "永久死亡"
}
},
{
"source": "hack",
"targets": {
"ko": "해킹",
"ja": "ハッキング",
"zh": "黑入"
}
},
{
"source": "hacked",
"targets": {
"ko": "해킹됐다",
"ja": "ハックされた",
"zh": "被黑了"
},
"whole_only": true
},
{
"source": "remote hack",
"targets": {
"ko": "원격 해킹",
"ja": "リモートハック",
"zh": "远程黑入"
}
},
{
"source": "backdoor",
"targets": {
"ko": "백도어",
"ja": "バックドア",
"zh": "后门"
}
},
{
"source": "firewall",
"targets": {
"ko": "방화벽",
"ja": "ファイアウォール",
"zh": "防火墙"
}
},
{
"source": "exploit",
"targets": {
"ko": "익스플로잇",
"ja": "エクスプロイト",
"zh": "漏洞利用"
}
},
{
"source": "payload",
"targets": {
"ko": "페이로드",
"ja": "ペイロード",
"zh": "载荷"
}
},
{
"source": "botnet",
"targets": {
"ko": "봇넷",
"ja": "ボットネット",
"zh": "僵尸网络"
}
},
{
"source": "server farm",
"targets": {
"ko": "서버 팜",
"ja": "サーバーファーム",
"zh": "服务器农场"
}
},
{
"source": "data breach",
"targets": {
"ko": "정보 유출",
"ja": "情報漏洩",
"zh": "数据泄露"
}
},
{
"source": "encryption",
"targets": {
"ko": "암호화",
"ja": "暗号化",
"zh": "加密"
}
},
{
"source": "profiler",
"targets": {
"ko": "프로파일러",
"ja": "プロファイラー",
"zh": "分析器"
}
},
{
"source": "profiling",
"targets": {
"ko": "신원 조회",
"ja": "プロファイリング",
"zh": "身份分析"
}
},
{
"source": "surveillance",
"targets": {
"ko": "감시",
"ja": "監視",
"zh": "监控"
}
},
{
"source": "security camera",
"targets": {
"ko": "감시 카메라",
"ja": "防犯カメラ",
"zh": "监控摄像头"
}
},
{
"source": "jump to camera",
"targets": {
"ko": "카메라 이동",
"ja": "カメラに移動",
"zh": "切换摄像头"
}
},
{
"source": "NetHack",
"targets": {
"ko": "넷핵",
"ja": "ネットハック",
"zh": "网络视野"
}
},
{
"source": "spiderbot",
"targets": {
"ko": "스파이더봇",
"ja": "スパイダーボット",
"zh": "蜘蛛机器人"
}
},
{
"source": "quadcopter",
"targets": {
"ko": "쿼드콥터",
"ja": "クアッドコプター",
"zh": "四轴飞行器"
}
},
{
"source": "jumper",
"targets": {
"ko": "점퍼",
"ja": "ジャンパー",
"zh": "跳跳车"
}
},
{
"source": "RC car",
"targets": {
"ko": "RC카",
"ja": "ラジコン",
"zh": "遥控车"
}
},
{
"source": "cargo drone",
"targets": {
"ko": "화물 드론",
"ja": "カーゴドローン",
"zh": "货运无人机"
}
},
{
"source": "turret",
"targets": {
"ko": "터렛",
"ja": "タレット",
"zh": "炮塔"
}
},
{
"source": "guard",
"targets": {
"ko": "경비원",
"ja": "警備員",
"zh": "守卫"
}
},
{
"source": "restricted area",
"targets": {
"ko": "제한 구역",
"ja": "立ち入り禁止区域",
"zh": "禁区"
}
},
{
"source": "detected",
"targets": {
"ko": "발각됐다",
"ja": "見つかった",
"zh": "被发现了"
},
"whole_only": true
},
{
"source": "stay hidden",
"targets": {
"ko": "숨어 있어",
"ja": "隠れてろ",
"zh": "别暴露"
},
"whole_only": true
},
{
"source": "take down",
"targets": {
"ko": "제압",
"ja": "テイクダウン",
"zh": "制服"
},
"whole_only": true
},
{
"source": "non lethal",
"targets": {
"ko": "비살상",
"ja": "非殺傷",
"zh": "非致命"
}
},
{
"source": "lethal",
"targets": {
"ko": "살상",
"ja": "殺傷",
"zh": "致命"
},
"whole_only": true
},
{
"source": "heat level",
"targets": {
"ko": "수배 단계",
"ja": "指名手配レベル",
"zh": "通缉等级"
}
},
{
"source": "wanted",
"targets": {
"ko": "수배 중",
"ja": "指名手配",
"zh": "被通缉"
}
},
{
"source": "lose the cops",
"targets": {
"ko": "경찰 따돌려",
"ja": "警察撒いて",
"zh": "甩掉警察"
},
"whole_only": true
},
{
"source": "traffic light",
"targets": {
"ko": "신호등",
"ja": "信号機",
"zh": "红绿灯"
}
},
{
"source": "steam pipe",
"targets": {
"ko": "증기관",
"ja": "蒸気管",
"zh": "蒸汽管道"
}
},
{
"source": "blocker",
"targets": {
"ko": "차단봉",
"ja": "ブロッカー",
"zh": "路障"
}
},
{
"source": "escape",
"targets": {
"ko": "도주",
"ja": "逃走",
"zh": "逃脱"
}
},
{
"source": "mission",
"targets": {
"ko": "임무",
"ja": "ミッション",
"zh": "任务"
}
},
{
"source": "side mission",
"targets": {
"ko": "서브 임무",
"ja": "サブミッション",
"zh": "支线任务"
}
},
{
"source": "checkpoint",
"targets": {
"ko": "체크포인트",
"ja": "チェックポイント",
"zh": "检查点"
}
},
{
"source": "borough",
"targets": {
"ko": "자치구",
"ja": "区",
"zh": "行政区"
}
},
{
"source": "uprising",
"targets": {
"ko": "봉기",
"ja": "蜂起",
"zh": "起义"
}
},
{
"source": "resistance",
"targets": {
"ko": "저항군",
"ja": "レジスタンス",
"zh": "抵抗组织"
}
},
{
"source": "whistleblower",
"targets": {
"ko": "내부 고발자",
"ja": "内部告発者",
"zh": "举报人"
}
},
{
"source": "privacy",
"targets": {
"ko": "사생활",
"ja": "プライバシー",
"zh": "隐私"
}
},
{
"source": "the city is watching",
"targets": {
"ko": "도시가 지켜보고 있다",
"ja": "街が見ている",
"zh": "城市在注视"
},
"whole_only": true
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

213
src/livesub/storage.py Normal file
View File

@@ -0,0 +1,213 @@
"""데이터를 어디에 둘지 — 사용자가 고르고, 그 선택을 기억한다.
닭과 달걀 문제가 하나 있다. 모델·설정·기록을 어디에 둘지는 사용자가 정하는데,
그 "정한 위치" 자체는 어딘가 **고정된 곳**에 적어둬야 다음 실행 때 찾을 수 있다.
그래서 OS 표준 설정 폴더에는 위치를 가리키는 쪽지 한 장(`location.json`)만 두고,
실제 내용물(config.json · models/ · glossary.json · transcripts/ · livesub.log)은
전부 사용자가 고른 폴더에 둔다.
왜 고르게 하나 — 모델이 품질 티어에 따라 2~16GB 를 차지한다. C 드라이브가 좁은
사람은 다른 디스크에 둬야 하는데, `%APPDATA%` 에 박아두면 방법이 없다.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import sys
from pathlib import Path
log = logging.getLogger(__name__)
POINTER_NAME = "location.json"
#: 저장 위치를 옮길 때 따라가는 것들. 여기 없는 파일은 건드리지 않는다.
#:
#: livesub.log 는 일부러 뺐다. 실행 중에는 로그 핸들러가 파일을 잡고 있어서
#: Windows 에서 이동이 실패한다. 로그는 다음 실행부터 새 위치에 쌓인다.
DATA_ENTRIES = ("config.json", "glossary.json", "models", "adapters", "transcripts")
def _os_config_dir() -> Path:
"""OS 표준 설정 폴더. 여기에는 쪽지 한 장만 둔다."""
from .constants import APP_NAME
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / APP_NAME
def pointer_path() -> Path:
return _os_config_dir() / POINTER_NAME
def default_data_dir() -> Path:
"""아무것도 고르지 않았을 때 쓰는 위치.
OS 표준 폴더와 같은 곳이다. 위치 선택이 없던 이전 버전이 쓰던 자리라,
기본값을 여기로 두면 이미 모델을 받아둔 사람이 다시 받지 않아도 된다.
"""
return _os_config_dir()
def _read_pointer() -> Path | None:
try:
raw = json.loads(pointer_path().read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
value = raw.get("data_dir") if isinstance(raw, dict) else None
if not isinstance(value, str) or not value.strip():
return None
return Path(value)
def stored_data_dir() -> Path | None:
"""적어둔 위치. 아직 안 골랐거나 쪽지가 깨졌으면 None."""
return _read_pointer()
def is_configured() -> bool:
"""위치 선택 화면을 건너뛰어도 되는지.
쪽지가 있으면 당연히 건너뛴다. 쪽지가 없어도 기본 위치에 이미 쓰던 흔적이
있으면 이전 버전 사용자이므로 묻지 않는다 — 잘 쓰고 있던 사람에게 갑자기
위치를 고르라고 하면 같은 자리를 다시 고르는 헛수고만 시킨다.
"""
if _read_pointer() is not None:
return True
base = default_data_dir()
return any((base / name).exists() for name in DATA_ENTRIES)
def data_dir() -> Path:
"""실제로 쓰는 데이터 폴더. 없으면 만든다."""
target = _read_pointer() or default_data_dir()
try:
target.mkdir(parents=True, exist_ok=True)
except OSError as exc:
# 고른 디스크가 빠졌거나 권한이 사라진 경우. 조용히 죽는 것보다는
# 기본 위치로 물러나 뜨는 편이 낫다.
log.warning("저장 위치(%s)를 쓸 수 없어 기본 위치를 씁니다: %s", target, exc)
target = default_data_dir()
target.mkdir(parents=True, exist_ok=True)
return target
def set_data_dir(path: Path | str) -> Path:
"""이 위치를 쓰겠다고 적어둔다."""
target = Path(path).expanduser()
target.mkdir(parents=True, exist_ok=True)
target = target.resolve()
pointer = pointer_path()
pointer.parent.mkdir(parents=True, exist_ok=True)
tmp = pointer.with_suffix(".json.tmp")
tmp.write_text(
json.dumps({"data_dir": str(target)}, ensure_ascii=False, indent=2), encoding="utf-8"
)
tmp.replace(pointer)
return target
def _existing_ancestor(path: Path) -> Path | None:
"""실제로 존재하는 가장 가까운 상위 폴더. 없으면 None."""
probe = path
while not probe.exists():
if probe.parent == probe:
return None
probe = probe.parent
return probe
def check_target(path: Path | str, current: Path | None = None) -> str | None:
"""고른 폴더를 쓸 수 있는지 본다.
문제가 없으면 None, 있으면 사용자에게 그대로 보여줄 한 줄을 돌려준다.
"""
text = str(path).strip()
if not text:
return "폴더를 골라주세요."
try:
candidate = Path(text).expanduser().resolve()
except (OSError, ValueError):
return "경로를 이해할 수 없습니다."
if current is not None:
try:
cur = Path(current).expanduser().resolve()
except (OSError, ValueError):
cur = None
if cur is not None and cur != candidate and cur in candidate.parents:
# 지금 위치 안쪽으로 옮기면 옮기는 도중에 자기 자신을 삼킨다.
return "지금 쓰는 폴더의 하위 폴더는 고를 수 없습니다."
anchor = _existing_ancestor(candidate)
if anchor is None:
return "드라이브를 찾을 수 없습니다."
if not anchor.is_dir():
return "폴더가 아니라 파일을 고르셨습니다."
if not os.access(anchor, os.W_OK):
return "이 폴더에 쓸 권한이 없습니다."
return None
def free_space(path: Path | str) -> int:
"""그 경로가 놓일 디스크의 남은 바이트. 알 수 없으면 0."""
anchor = _existing_ancestor(Path(str(path)).expanduser())
if anchor is None:
return 0
try:
return shutil.disk_usage(anchor).free
except OSError:
return 0
def human_size(num: float) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if num < 1024 or unit == "TB":
return f"{num:,.1f} {unit}" if unit != "B" else f"{num:,.0f} B"
num /= 1024
return f"{num:,.1f} TB"
def used_space(path: Path | str) -> int:
"""그 폴더가 실제로 쓰고 있는 바이트. 읽을 수 없는 항목은 건너뛴다."""
total = 0
for root, _dirs, files in os.walk(str(path), onerror=lambda _e: None):
for name in files:
try:
total += os.path.getsize(os.path.join(root, name))
except OSError:
continue
return total
def move_data(old: Path | str, new: Path | str) -> list[str]:
"""기존 데이터를 새 위치로 옮기고, 옮긴 항목 이름을 돌려준다."""
src_root = Path(old).expanduser().resolve()
dst_root = Path(new).expanduser()
dst_root.mkdir(parents=True, exist_ok=True)
dst_root = dst_root.resolve()
if src_root == dst_root:
return []
moved: list[str] = []
for name in DATA_ENTRIES:
src = src_root / name
if not src.exists():
continue
dst = dst_root / name
if dst.exists():
# 새 위치에 이미 같은 이름이 있으면 덮어쓰지 않는다. 예전에 쓰던
# 폴더를 다시 고른 경우일 수 있고, 그때 지우면 복구할 방법이 없다.
log.info("이미 있어서 건너뜀: %s", dst)
continue
shutil.move(str(src), str(dst))
moved.append(name)
return moved

View File

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

View File

@@ -0,0 +1,211 @@
"""저장 위치를 고르는 화면.
첫 실행 때 한 번 묻고, 그 뒤로는 설정에서 바꿀 수 있다. 두 경우가 묻는 내용이
같으므로 같은 대화상자를 쓰고 문구만 바꾼다.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QProgressDialog,
QPushButton,
QVBoxLayout,
QWidget,
)
from ..constants import APP_NAME
from ..storage import (
DATA_ENTRIES,
check_target,
data_dir,
default_data_dir,
free_space,
human_size,
move_data,
set_data_dir,
)
#: 가장 무거운 티어까지 받았을 때를 기준으로 한 권장 여유 공간.
RECOMMENDED_FREE = 20 * 1024**3
class DataLocationDialog(QDialog):
"""폴더 하나를 고르고, 쓸 수 있는 곳인지 즉시 알려준다."""
def __init__(self, start: Path, *, first_run: bool, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle(f"{APP_NAME} 저장 위치")
self.setMinimumWidth(520)
self._first_run = first_run
self._current = None if first_run else start
root = QVBoxLayout(self)
root.setSpacing(12)
title = QLabel("어디에 저장할까요?" if first_run else "저장 위치 바꾸기")
title.setStyleSheet("font-size: 16px; font-weight: 700;")
root.addWidget(title)
why = QLabel(
"음성인식·번역 모델과 설정, 번역 기록이 이 폴더에 저장됩니다.\n"
"모델은 품질 티어에 따라 2~16GB 를 차지하니 여유 있는 디스크를 고르세요.\n"
"나중에 설정에서 언제든 바꿀 수 있습니다."
)
why.setObjectName("Hint")
why.setWordWrap(True)
root.addWidget(why)
row = QWidget()
row.setObjectName("Row")
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
self.path_edit = QLineEdit(str(start))
self.path_edit.textChanged.connect(self._revalidate)
browse = QPushButton("찾아보기…")
browse.clicked.connect(self._browse)
row_layout.addWidget(self.path_edit, 1)
row_layout.addWidget(browse)
root.addWidget(row)
self.status = QLabel()
self.status.setWordWrap(True)
root.addWidget(self.status)
self.buttons = QDialogButtonBox()
ok_text = "이 위치로 시작" if first_run else "이 위치로 바꾸기"
self.ok_button = self.buttons.addButton(ok_text, QDialogButtonBox.ButtonRole.AcceptRole)
cancel_text = "종료" if first_run else "취소"
self.buttons.addButton(cancel_text, QDialogButtonBox.ButtonRole.RejectRole)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
self._revalidate()
def selected(self) -> Path:
return Path(self.path_edit.text().strip()).expanduser()
def _browse(self) -> None:
start = self.path_edit.text().strip() or str(default_data_dir())
picked = QFileDialog.getExistingDirectory(self, "저장할 폴더 선택", start)
if picked:
self.path_edit.setText(picked)
def _revalidate(self) -> None:
text = self.path_edit.text().strip()
problem = check_target(text, self._current)
self.ok_button.setEnabled(problem is None)
if problem is not None:
self.status.setText(f"⚠ {problem}")
self.status.setStyleSheet("color: #E5484D;")
return
free = free_space(text)
line = f"이 디스크의 남은 공간 {human_size(free)}"
if free and free < RECOMMENDED_FREE:
self.status.setText(
f"{line} — 높은 티어 모델까지 받기에는 빠듯합니다. 그래도 진행할 수 있습니다."
)
self.status.setStyleSheet("color: #F5A623;")
else:
self.status.setText(line)
self.status.setStyleSheet("")
def ask_initial_location(parent: QWidget | None = None) -> bool:
"""첫 실행 — 위치를 고르게 한다. 사용자가 종료를 누르면 False."""
dialog = DataLocationDialog(default_data_dir(), first_run=True, parent=parent)
if dialog.exec() != QDialog.DialogCode.Accepted:
return False
set_data_dir(dialog.selected())
return True
def ask_change_location(parent: QWidget | None = None) -> Path | None:
"""설정 — 위치를 바꾼다. 바꿨으면 새 경로, 취소했으면 None."""
current = data_dir()
dialog = DataLocationDialog(current, first_run=False, parent=parent)
if dialog.exec() != QDialog.DialogCode.Accepted:
return None
target = dialog.selected()
if target.expanduser().resolve() == current.resolve():
return None
if _has_data(current) and not _move_with_progress(current, target, parent):
return None
return set_data_dir(target)
def _has_data(path: Path) -> bool:
return any((path / name).exists() for name in DATA_ENTRIES)
def _move_with_progress(old: Path, new: Path, parent: QWidget | None) -> bool:
"""기존 데이터를 옮길지 묻고, 옮기기로 했으면 옮긴다. 취소면 False."""
answer = QMessageBox.question(
parent,
"기존 데이터",
f"지금 위치에 받아둔 모델과 설정이 있습니다.\n\n"
f"{old}\n\n"
"새 위치로 옮길까요? 옮기지 않으면 모델을 다시 받아야 합니다.",
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Yes,
)
if answer == QMessageBox.StandardButton.Cancel:
return False
if answer == QMessageBox.StandardButton.No:
return True
# 수 GB 를 옮기는 동안 창이 얼어붙으면 Windows 가 "응답 없음"을 띄운다.
# 별도 스레드로 옮기고 여기서는 진행 표시만 돌린다.
worker = _MoveWorker(old, new)
progress = QProgressDialog("기존 데이터를 옮기는 중입니다…", "", 0, 0, parent)
progress.setWindowTitle("저장 위치 변경")
progress.setCancelButton(None) # 옮기는 도중에 끊으면 데이터가 갈라진다
progress.setWindowModality(Qt.WindowModality.WindowModal)
worker.finished.connect(progress.close)
worker.start()
progress.exec()
worker.wait()
if worker.error:
QMessageBox.warning(
parent,
"옮기지 못했습니다",
f"기존 데이터를 옮기는 중 문제가 생겼습니다.\n\n{worker.error}\n\n"
"저장 위치는 바꾸지 않았습니다.",
)
return False
return True
class _MoveWorker(QThread):
done = Signal()
def __init__(self, old: Path, new: Path):
super().__init__()
self._old = old
self._new = new
self.moved: list[str] = []
self.error = ""
def run(self) -> None:
try:
self.moved = move_data(self._old, self._new)
except OSError as exc:
self.error = str(exc)
self.done.emit()

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

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

View File

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

519
src/livesub/ui/overlay.py Normal file
View File

@@ -0,0 +1,519 @@
"""자막 오버레이 창.
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
위치 조정 모드에서:
* 드래그 — 위치 이동
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
* 휠 — 글자 크기 조절
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
"""
from __future__ import annotations
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QColor,
QFont,
QFontMetrics,
QPainter,
QPainterPath,
QPen,
)
from PySide6.QtWidgets import QMenu, QWidget
from ..config import OverlayConfig, SubtitleStyle
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
GRIP = 18 # 우하단 리사이즈 핸들 크기
def paint_subtitle(
painter: QPainter,
rect: QRect,
st: SubtitleStyle,
translated: str,
source: str = "",
partial: str = "",
) -> None:
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다.
배경 상자는 창 전체가 아니라 **글자를 감싸는 크기**로 그린다. 창 전체를
칠하면 자막이 짧아도 커다란 띠가 남고, 그 상태에서는 여백을 키워도 글자만
안쪽으로 밀릴 뿐 '배경 크기'는 그대로다. 감싸도록 그려야 여백이 곧 배경
크기가 된다.
"""
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
rows: list[tuple[str, QColor, int]] = []
if st.show_source and source and translated:
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
main = translated or partial
if main:
color = QColor(st.text_color)
if not translated: # 아직 확정 전 — 흐리게
color.setAlpha(170)
rows.append((main, color, st.font_size))
if not rows:
return
pad = max(0, st.padding)
available = rect.width() - pad * 2
blocks: list[tuple[list[str], QColor, QFont]] = []
total_h = 0
text_w = 0
for text, color, size in rows:
font = QFont(st.font_family, size)
font.setBold(st.bold)
fm = QFontMetrics(font)
lines = _wrap(text, fm, available, st.max_lines)
blocks.append((lines, color, font))
total_h += len(lines) * (fm.height() + st.line_spacing)
for line in lines:
text_w = max(text_w, fm.horizontalAdvance(line))
box = _content_box(rect, text_w, total_h, pad, st.align)
if st.background_opacity > 0:
bg = QColor(st.background_color)
bg.setAlpha(int(255 * st.background_opacity / 100))
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(box, 12, 12)
y = box.y() + pad
for lines, color, font in blocks:
painter.setFont(font)
fm = QFontMetrics(font)
for line in lines:
width = fm.horizontalAdvance(line)
if st.align == "left":
x = box.x() + pad
elif st.align == "right":
x = box.right() - pad - width
else:
x = box.x() + (box.width() - width) // 2
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
y += fm.height() + st.line_spacing
def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect:
"""글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다."""
width = min(rect.width(), text_w + pad * 2)
height = min(rect.height(), text_h + pad * 2)
if align == "left":
x = rect.x()
elif align == "right":
x = rect.right() - width + 1
else:
x = rect.x() + (rect.width() - width) // 2
y = rect.y() + (rect.height() - height) // 2
return QRect(x, y, width, height)
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
"""외곽선 있는 글자.
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
"""
if st.outline_width <= 0:
painter.setPen(color)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawText(x, y, text)
return
path = QPainterPath()
path.addText(x, y, font, text)
pen = QPen(QColor(st.outline_color))
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPath(path)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPath(path)
class SubtitlePreview(QWidget):
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
super().__init__(parent)
self.style_cfg = style
self.translated = ""
self.source = ""
self.setMinimumHeight(140)
def set_sample(self, translated: str, source: str) -> None:
self.translated = translated
self.source = source
self.update()
def paintEvent(self, event) -> None: # noqa: N802
painter = QPainter(self)
paint_subtitle(
painter, self.rect(), self.style_cfg, self.translated, self.source
)
class SubtitleOverlay(QWidget):
"""항상 위에 떠 있는 자막 창."""
geometry_changed = Signal(int, int, int, int)
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
visibility_changed = Signal(bool)
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
font_size_changed = Signal(int)
#: 위치 조정 모드가 켜지고 꺼질 때
edit_mode_changed = Signal(bool)
closed = Signal()
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
super().__init__(None)
self.style_cfg = style
self.overlay_cfg = overlay
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self._drag_from: QPoint | None = None
#: 위치 조정 모드. 설정에 저장하지 않는다 — 다음 실행 때는 다시 잠긴
#: 상태로 시작해야 게임 중에 자막을 실수로 끌고 다니지 않는다.
self._edit_mode = False
self._resize_corner = ""
self._resize_geo = QRect()
self._resize_mouse = QPoint()
self._resize_font = 0
self._fade = QTimer(self)
self._fade.setSingleShot(True)
self._fade.timeout.connect(self._clear_text)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint
| Qt.WindowType.Tool
| Qt.WindowType.WindowStaysOnTopHint
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMinimumSize(320, 90)
self.apply_config()
# --- 외부 API -------------------------------------------------------
def apply_config(self) -> None:
cfg = self.overlay_cfg
flags = self.windowFlags()
flags = (
flags | Qt.WindowType.WindowStaysOnTopHint
if cfg.always_on_top
else flags & ~Qt.WindowType.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
# 창을 그냥 지나가서 잡을 수가 없다.
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
cfg.click_through and not self._edit_mode,
)
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
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)
self.setVisible(cfg.visible)
self.update()
# --- 위치 조정 모드 --------------------------------------------------
@property
def edit_mode(self) -> bool:
return self._edit_mode
def set_edit_mode(self, on: bool) -> None:
"""자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다.
평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이
없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이
동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다.
"""
if on == self._edit_mode:
return
self._edit_mode = on
if on and not self.isVisible():
self.setVisible(True)
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
self.overlay_cfg.click_through and not on,
)
self.setCursor(
Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor
)
if not on:
self._drag_from = None
self._resize_corner = ""
self.update()
self.edit_mode_changed.emit(on)
@property
def _interactive(self) -> bool:
return self._edit_mode or not self.overlay_cfg.locked
def _corner_at(self, pos: QPoint) -> str:
"""모서리 손잡이를 집었는지. 집었으면 어느 모서리인지."""
left = pos.x() <= GRIP
right = pos.x() >= self.width() - GRIP
top = pos.y() <= GRIP
bottom = pos.y() >= self.height() - GRIP
if (left or right) and (top or bottom):
return ("top" if top else "bottom") + ("left" if left else "right")
return ""
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
self._partial_text = ""
self._source_text = source
else:
self._partial_text = source
self._restart_fade()
self.update()
def clear(self) -> None:
self._clear_text()
# --- 그리기 ---------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
painter = QPainter(self)
paint_subtitle(
painter,
self.rect(),
self.style_cfg,
self._final_text,
self._source_text,
self._partial_text,
)
if self._edit_mode:
self._draw_edit_chrome(painter)
def _draw_edit_chrome(self, painter) -> None:
"""위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(90, 170, 255, 235))
for rect in self._corner_handles():
painter.drawRect(rect)
if not (self._final_text or self._partial_text):
painter.setPen(QColor(255, 255, 255, 170))
font = QFont(self.style_cfg.font_family, 13)
painter.setFont(font)
painter.drawText(
self.rect(),
Qt.AlignmentFlag.AlignCenter,
"끌어서 이동 · 모서리를 잡아 크기 조절 (글자 크기도 같이 바뀝니다)",
)
def _corner_handles(self) -> list[QRect]:
w, h = self.width(), self.height()
return [
QRect(0, 0, GRIP, GRIP),
QRect(w - GRIP, 0, GRIP, GRIP),
QRect(0, h - GRIP, GRIP, GRIP),
QRect(w - GRIP, h - GRIP, GRIP, GRIP),
]
# --- 마우스 ---------------------------------------------------------
def mousePressEvent(self, event) -> None: # noqa: N802
if not self._interactive or event.button() != Qt.MouseButton.LeftButton:
return
corner = self._corner_at(event.position().toPoint())
if corner:
self._resize_corner = corner
self._resize_geo = QRect(self.geometry())
self._resize_mouse = event.globalPosition().toPoint()
self._resize_font = self.style_cfg.font_size
else:
self._drag_from = event.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, event) -> None: # noqa: N802
if self._resize_corner:
self._resize_to(event.globalPosition().toPoint())
elif self._drag_from is not None:
self.move(event.globalPosition().toPoint() - self._drag_from)
def _resize_to(self, mouse: QPoint) -> None:
"""잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다.
글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면
여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다.
"""
start = self._resize_geo
dx = mouse.x() - self._resize_mouse.x()
dy = mouse.y() - self._resize_mouse.y()
left, top = start.left(), start.top()
right, bottom = start.right(), start.bottom()
if "left" in self._resize_corner:
left = min(left + dx, right - self.minimumWidth())
else:
right = max(right + dx, left + self.minimumWidth())
if "top" in self._resize_corner:
top = min(top + dy, bottom - self.minimumHeight())
else:
bottom = max(bottom + dy, top + self.minimumHeight())
rect = QRect(QPoint(left, top), QPoint(right, bottom))
self.setGeometry(rect)
scaled = round(self._resize_font * rect.height() / max(1, start.height()))
size = max(12, min(120, scaled))
if size != self.style_cfg.font_size:
self.style_cfg.font_size = size
self.font_size_changed.emit(size)
self.update()
def mouseReleaseEvent(self, event) -> None: # noqa: N802
if self._drag_from is None and not self._resize_corner:
return
self._drag_from = None
self._resize_corner = ""
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 not self._interactive:
return
step = 2 if event.angleDelta().y() > 0 else -2
size = max(12, min(120, self.style_cfg.font_size + step))
if size != self.style_cfg.font_size:
self.style_cfg.font_size = size
self.font_size_changed.emit(size)
self.update()
def contextMenuEvent(self, event) -> None: # noqa: N802
menu = QMenu(self)
cfg = self.overlay_cfg
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
lock.toggled.connect(self._set_locked)
menu.addAction(lock)
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
through.toggled.connect(self._set_click_through)
menu.addAction(through)
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
top.toggled.connect(self._set_always_on_top)
menu.addAction(top)
menu.addSeparator()
menu.addAction("자막 지우기", self.clear)
menu.addAction("자막 창 숨기기", self.hide_overlay)
menu.exec(event.globalPos())
# --- 메뉴 동작 ------------------------------------------------------
def _set_locked(self, value: bool) -> None:
self.overlay_cfg.locked = value
self.update()
def _set_click_through(self, value: bool) -> None:
self.overlay_cfg.click_through = value
# 위치 조정 중이면 통과를 켜지 않는다. 켜는 순간 창을 못 잡게 된다.
self.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
value and not self._edit_mode,
)
self.update()
def _set_always_on_top(self, value: bool) -> None:
self.overlay_cfg.always_on_top = value
self.apply_config()
def hide_overlay(self) -> None:
self.set_subtitle_visible(False)
self.closed.emit()
def _restart_fade(self) -> None:
self._fade.stop()
if self.style_cfg.fade_out_ms > 0:
self._fade.start(self.style_cfg.fade_out_ms)
def _clear_text(self) -> None:
self._final_text = ""
self._partial_text = ""
self._source_text = ""
self.update()
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
if width <= 0 or not text:
return [text] if text else []
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if fm.horizontalAdvance(candidate) <= width or not current:
current = candidate
else:
lines.append(current)
current = word
if current:
lines.append(current)
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
return lines[-max_lines:] if max_lines > 0 else lines

View File

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

View File

@@ -0,0 +1,336 @@
"""용어집 — 게임·방송 고유명사를 원하는 번역으로 고정한다."""
from __future__ import annotations
import csv
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QFileDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...constants import LANGUAGE_CODES, LANGUAGES, user_data_dir
from ...models import Glossary, GlossaryEntry
from ...models.packs import available_packs, find_conflicts
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
COLUMNS = ["원문 용어", *[f"{LANGUAGES[c]['label']} 역어" for c in LANGUAGE_CODES], "메모"]
class GlossaryPage(QWidget):
glossary_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self.path = Path(config.glossary.path or user_data_dir() / "glossary.json")
self.glossary = Glossary.load(self.path, config.glossary.case_sensitive)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"용어집",
"여기 등록한 단어는 번역 모델이 마음대로 바꾸지 못하고 지정한 역어로 고정됩니다. "
"모델을 추가로 학습시키지 않아도 바로 적용됩니다.",
)
)
root.addWidget(self._build_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 sorted(packs, key=lambda p: (not p.is_generic, p.name)):
suffix = " · 장르 공통" if pack.is_generic else ""
check = QCheckBox(f"{pack.name} ({len(pack)}개){suffix}")
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.conflict_label = card.add(_warning(""))
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}개")
self._update_conflicts()
def _update_conflicts(self) -> None:
"""게임 전용 팩끼리 역어가 갈리는 단어를 알려준다.
예를 들어 오버워치와 워치독스를 같이 켜면 payload 가 '화물'과
'페이로드'로 갈린다. 조용히 아무거나 고르면 사용자는 영문도 모른 채
이상한 자막을 보게 되므로, 어떤 단어가 문제인지 그대로 보여준다.
"""
conflicts = find_conflicts(self.config.glossary.enabled_packs)
if not conflicts:
self.conflict_label.setText("")
self.conflict_label.setVisible(False)
return
shown = sorted(conflicts.items())[:4]
parts = [
f"{source} ({' / '.join(t for _, t in owners)})" for source, owners in shown
]
more = f" 외 {len(conflicts) - len(shown)}개" if len(conflicts) > len(shown) else ""
self.conflict_label.setText(
"게임 팩끼리 역어가 갈리는 단어가 있습니다 — "
+ ", ".join(parts)
+ more
+ ". 안 하는 게임은 꺼주세요. (직접 등록하면 그쪽이 이깁니다)"
)
self.conflict_label.setVisible(True)
# --- 구성 -----------------------------------------------------------
def _build_options_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.enabled_check = QCheckBox("용어집 사용")
self.enabled_check.setChecked(self.config.glossary.enabled)
self.enabled_check.toggled.connect(self._on_enabled)
layout.addWidget(self.enabled_check)
self.case_check = QCheckBox("대소문자 구분")
self.case_check.setChecked(self.config.glossary.case_sensitive)
self.case_check.toggled.connect(self._on_case)
layout.addWidget(self.case_check)
layout.addStretch(1)
for label, slot in (
("CSV 가져오기", self._import_csv),
("CSV 내보내기", self._export_csv),
):
button = QPushButton(label)
button.clicked.connect(slot)
layout.addWidget(button)
card.add(row)
return card
def _build_table_card(self) -> Card:
card = Card(
"등록된 용어",
"역어를 비워두면 그 언어에서는 규칙을 적용하지 않습니다. "
"CSV 형식: 원문,한국어,English,日本語,中文,메모",
)
search_row = QWidget()
search_layout = QHBoxLayout(search_row)
search_layout.setContentsMargins(0, 0, 0, 0)
search_layout.setSpacing(10)
self.search = QLineEdit()
self.search.setPlaceholderText("용어 검색…")
self.search.textChanged.connect(self._filter)
search_layout.addWidget(self.search, 1)
for label, slot in (("행 추가", self._add_row), ("선택 삭제", self._delete_rows)):
button = QPushButton(label)
button.clicked.connect(slot)
search_layout.addWidget(button)
card.add(search_row)
self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table.itemChanged.connect(self._on_item_changed)
card.add(self.table)
self.count_label = card.add(
_hint(f"{len(self.glossary)}개 등록됨")
)
return card
# --- 데이터 ---------------------------------------------------------
def _reload_table(self) -> None:
self.table.blockSignals(True)
self.table.setRowCount(0)
for entry in self.glossary.entries:
self._append_row(entry)
self.table.blockSignals(False)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
def _append_row(self, entry: GlossaryEntry) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
values = [entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
for col, value in enumerate(values):
self.table.setItem(row, col, QTableWidgetItem(value))
def _collect(self) -> list[GlossaryEntry]:
entries: list[GlossaryEntry] = []
for row in range(self.table.rowCount()):
source_item = self.table.item(row, 0)
source = source_item.text().strip() if source_item else ""
if not source:
continue
targets: dict[str, str] = {}
for i, code in enumerate(LANGUAGE_CODES, start=1):
item = self.table.item(row, i)
text = item.text().strip() if item else ""
if text:
targets[code] = text
note_item = self.table.item(row, len(COLUMNS) - 1)
entries.append(
GlossaryEntry(
source=source,
targets=targets,
note=note_item.text().strip() if note_item else "",
)
)
return entries
def _save(self) -> None:
self.glossary = Glossary(self._collect(), self.config.glossary.case_sensitive)
self.glossary.save(self.path)
self.count_label.setText(f"{len(self.glossary)}개 등록됨")
self.glossary_changed.emit()
# --- 동작 -----------------------------------------------------------
def _on_item_changed(self, *_) -> None:
self._save()
def _on_enabled(self, value: bool) -> None:
self.config.glossary.enabled = value
self.glossary_changed.emit()
def _on_case(self, value: bool) -> None:
self.config.glossary.case_sensitive = value
self._save()
def _add_row(self) -> None:
self.table.blockSignals(True)
self._append_row(GlossaryEntry(source=""))
self.table.blockSignals(False)
self.table.editItem(self.table.item(self.table.rowCount() - 1, 0))
def _delete_rows(self) -> None:
rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True)
if not rows:
return
self.table.blockSignals(True)
for row in rows:
self.table.removeRow(row)
self.table.blockSignals(False)
self._save()
def _filter(self, text: str) -> None:
needle = text.strip().casefold()
for row in range(self.table.rowCount()):
haystack = " ".join(
(self.table.item(row, c).text() if self.table.item(row, c) else "")
for c in range(self.table.columnCount())
).casefold()
self.table.setRowHidden(row, bool(needle) and needle not in haystack)
def _import_csv(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "CSV 가져오기", "", "CSV (*.csv)")
if not path:
return
try:
with open(path, encoding="utf-8-sig", newline="") as fh:
rows = [r for r in csv.reader(fh) if r and r[0].strip()]
except OSError as exc:
QMessageBox.warning(self, "가져오기 실패", str(exc))
return
# 첫 줄이 헤더처럼 보이면 건너뛴다.
if rows and rows[0][0].strip() in ("원문", "원문 용어", "source"):
rows = rows[1:]
added = 0
for row in rows:
cells = [c.strip() for c in row] + [""] * (len(COLUMNS) - len(row))
targets = {
code: cells[i]
for i, code in enumerate(LANGUAGE_CODES, start=1)
if cells[i]
}
self.glossary.add(
GlossaryEntry(source=cells[0], targets=targets, note=cells[len(COLUMNS) - 1])
)
added += 1
self.glossary.save(self.path)
self._reload_table()
self.glossary_changed.emit()
QMessageBox.information(self, "가져오기 완료", f"{added}개 용어를 반영했습니다.")
def _export_csv(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "CSV 내보내기", "glossary.csv", "CSV (*.csv)"
)
if not path:
return
with open(path, "w", encoding="utf-8-sig", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(COLUMNS)
for entry in self.glossary.entries:
writer.writerow(
[entry.source, *[entry.target_for(c) for c in LANGUAGE_CODES], entry.note]
)
def _hint(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("Hint")
label.setAlignment(Qt.AlignmentFlag.AlignRight)
return label
def _warning(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("Hint")
label.setWordWrap(True)
label.setStyleSheet(f"color: {palette('dark').warning};")
label.setVisible(bool(text))
return label

View File

@@ -0,0 +1,304 @@
"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from ...audio import (
SOURCE_MODE_INPUT,
SOURCE_MODE_OUTPUT,
capture_capabilities,
list_sources,
)
from ...config import AppConfig
from ...constants import LANGUAGES
from ...core.events import EngineState, EngineStatus, TranslationLine
from ..theme import SPACING, palette
from ..widgets import Card, LevelMeter, PageHeader, StatusPill
MAX_LOG_ROWS = 300
class HomePage(QWidget):
start_requested = Signal()
stop_requested = Signal()
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._sources: list = []
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.")
)
root.addWidget(self._build_source_card())
root.addWidget(self._build_control_card())
root.addWidget(self._build_log_card(), 1)
self.refresh_sources()
# --- 구성 -----------------------------------------------------------
def _build_source_card(self) -> Card:
card = Card("소리 받아올 곳", "")
self.capability_label = QLabel()
self.capability_label.setObjectName("Hint")
self.capability_label.setWordWrap(True)
card.add(self.capability_label)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(360)
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
layout.addWidget(self.source_combo, 1)
# 마이크 등 입력 장치는 같은 목록에 섞지 않고 전환해서 보여준다.
# 한 목록에 다 넣으면 정작 찾으려던 게임이 라인입력들 사이에 파묻힌다.
self.mode_button = QPushButton()
self.mode_button.setMinimumWidth(170)
self.mode_button.clicked.connect(self._toggle_source_mode)
layout.addWidget(self.mode_button)
refresh = QPushButton("새로고침")
refresh.clicked.connect(self.refresh_sources)
layout.addWidget(refresh)
card.add(row)
return card
@property
def _mode(self) -> str:
return (
SOURCE_MODE_INPUT
if self.config.audio.source_mode == SOURCE_MODE_INPUT
else SOURCE_MODE_OUTPUT
)
def _toggle_source_mode(self) -> None:
self.config.audio.source_mode = (
SOURCE_MODE_OUTPUT if self._mode == SOURCE_MODE_INPUT else SOURCE_MODE_INPUT
)
self.refresh_sources()
self.config_changed.emit()
def _build_control_card(self) -> Card:
card = Card()
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
self.source_lang = QComboBox()
self.source_lang.addItem("자동 감지", "auto")
for code, meta in LANGUAGES.items():
self.source_lang.addItem(meta["label"], code)
self.source_lang.setCurrentIndex(
max(0, self.source_lang.findData(self.config.models.source_lang))
)
self.source_lang.currentIndexChanged.connect(self._on_lang_changed)
self.target_lang = QComboBox()
for code, meta in LANGUAGES.items():
self.target_lang.addItem(meta["label"], code)
self.target_lang.setCurrentIndex(
max(0, self.target_lang.findData(self.config.models.target_lang))
)
self.target_lang.currentIndexChanged.connect(self._on_lang_changed)
layout.addWidget(QLabel("원본"))
layout.addWidget(self.source_lang)
arrow = QLabel("→")
arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;")
layout.addWidget(arrow)
layout.addWidget(QLabel("번역"))
layout.addWidget(self.target_lang)
self.speech_combo = QComboBox()
self.speech_combo.addItem("존댓말", "polite")
self.speech_combo.addItem("반말", "casual")
self.speech_combo.setCurrentIndex(
max(0, self.speech_combo.findData(self.config.models.speech_level))
)
self.speech_combo.setToolTip(
"한국어 자막의 말투입니다.\n"
"반말을 골라도 원문이 실제로 존댓말이면 존댓말로 나옵니다.\n"
"영어·중국어처럼 높임이 없는 언어는 이 설정을 그대로 따릅니다."
)
self.speech_combo.currentIndexChanged.connect(self._on_lang_changed)
layout.addWidget(QLabel("말투"))
layout.addWidget(self.speech_combo)
layout.addStretch(1)
self.status_pill = StatusPill()
layout.addWidget(self.status_pill)
self.toggle_button = QPushButton("번역 시작")
self.toggle_button.setObjectName("Primary")
self.toggle_button.setMinimumWidth(140)
self.toggle_button.clicked.connect(self._on_toggle)
layout.addWidget(self.toggle_button)
card.add(row)
self.level_meter = LevelMeter()
card.add(self.level_meter)
return card
def _build_log_card(self) -> Card:
card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.")
self.log_list = QListWidget()
self.log_list.setWordWrap(True)
self.log_list.setAlternatingRowColors(False)
card.add(self.log_list)
return card
# --- 동작 -----------------------------------------------------------
def refresh_sources(self) -> None:
caps = capture_capabilities()
mode = self._mode
self._sources = list_sources(mode)
icons = {"process": "🎮", "device": "🔊", "input": "🎤"}
self.source_combo.blockSignals(True)
self.source_combo.clear()
for src in self._sources:
prefix = icons.get(src.kind, "🔊")
self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src)
if not self._sources:
self.source_combo.addItem(
"마이크 등 입력 장치를 찾지 못했습니다"
if mode == SOURCE_MODE_INPUT
else "사용 가능한 오디오 대상이 없습니다",
None,
)
self._select_saved_source()
self.source_combo.blockSignals(False)
self._on_source_changed()
# 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다.
if mode == SOURCE_MODE_INPUT:
self.mode_button.setText("프로그램 소리에서 고르기")
self.mode_button.setToolTip("게임 등 프로그램과 출력 장치 목록으로 돌아갑니다.")
msg = (
"마이크 등 입력 장치로 듣습니다. 통화 상대 목소리나 내가 말하는 것을 "
"번역할 때 쓰세요."
if caps["input"]
else "이 환경에서는 입력 장치를 열 수 없습니다. Windows에서 실행하세요."
)
else:
self.mode_button.setText("마이크 입력에서 고르기")
self.mode_button.setToolTip("목록을 마이크 등 입력 장치로 바꿉니다.")
if caps["process"]:
msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다."
elif caps["device"]:
msg = (
"프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 "
"출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 "
"프로그램 단위로 분리됩니다."
)
else:
msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요."
self.capability_label.setText(msg)
def _select_saved_source(self) -> None:
cfg = self.config.audio
for i, src in enumerate(self._sources):
if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid:
self.source_combo.setCurrentIndex(i)
return
if (
cfg.backend in ("device", "input")
and src.kind == cfg.backend
and src.device_index == cfg.device_index
):
self.source_combo.setCurrentIndex(i)
return
def _on_source_changed(self, *_) -> None:
src = self.source_combo.currentData()
cfg = self.config.audio
if src is None:
return
if src.kind == "process":
cfg.backend = "process"
cfg.target_pid = src.pid
cfg.target_process_name = src.label
else:
cfg.backend = src.kind # "device" 또는 "input"
cfg.device_index = src.device_index
cfg.target_pid = 0
self.config_changed.emit()
def _on_lang_changed(self, *_) -> None:
self.config.models.source_lang = self.source_lang.currentData()
self.config.models.target_lang = self.target_lang.currentData()
self.config.models.speech_level = self.speech_combo.currentData()
# 한국어가 아니면 말투 규칙을 적용할 데가 없다.
self.speech_combo.setEnabled(self.config.models.target_lang == "ko")
self.config_changed.emit()
def _on_toggle(self) -> None:
if self.toggle_button.property("running"):
self.stop_requested.emit()
else:
self.start_requested.emit()
# --- 엔진 연동 ------------------------------------------------------
def on_status(self, status: EngineStatus) -> None:
running = status.state in (EngineState.RUNNING, EngineState.LOADING)
self.toggle_button.setProperty("running", running)
self.toggle_button.setText("번역 정지" if running else "번역 시작")
self.toggle_button.setObjectName("Danger" if running else "Primary")
self.toggle_button.style().polish(self.toggle_button)
kind = {
EngineState.RUNNING: "ok",
EngineState.LOADING: "busy",
EngineState.STOPPING: "busy",
EngineState.ERROR: "error",
}.get(status.state, "idle")
self.status_pill.set_status(status.message or status.state.value, kind)
self.level_meter.set_level(status.level)
for widget in (
self.source_combo,
self.mode_button,
self.source_lang,
self.target_lang,
):
widget.setEnabled(not running)
self.speech_combo.setEnabled(
not running and self.config.models.target_lang == "ko"
)
def on_line(self, line: TranslationLine) -> None:
if not line.is_final:
return
item = QListWidgetItem(
f"{line.translated_text}\n"
f" {line.source_text} · {line.latency_s:.1f}초 "
f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)"
)
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
item.setToolTip(line.source_text)
self.log_list.insertItem(0, item)
while self.log_list.count() > MAX_LOG_ROWS:
self.log_list.takeItem(self.log_list.count() - 1)
self.log_list.scrollToTop()

View File

@@ -0,0 +1,286 @@
"""모델 — 5단계 품질 티어 선택과 GPU 상태."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ...models import Tier, detect_gpu, get_tier, ordered_tiers
from ...models.manager import GpuInfo, recommended_tier, tier_availability
from ..theme import SPACING, palette
from ..widgets import Card, PageHeader
class TierCard(QFrame):
"""티어 하나를 라디오 버튼 카드로."""
def __init__(
self,
tier: Tier,
gpu: GpuInfo,
recommended: bool = False,
parent: QWidget | None = None,
):
super().__init__(parent)
self.tier = tier
gpu_vram_gb = gpu.total_vram_mb / 1024 if gpu.available else 0.0
self.available, self.unavailable_reason = tier_availability(tier, gpu)
self.setObjectName("Card")
p = palette("dark")
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 14)
layout.setSpacing(14)
self.radio = QRadioButton()
layout.addWidget(self.radio, 0, Qt.AlignmentFlag.AlignTop)
text = QVBoxLayout()
text.setSpacing(3)
head = QHBoxLayout()
head.setSpacing(8)
name = QLabel(f"{tier.order}. {tier.name}")
name.setStyleSheet("font-size: 16px; font-weight: 700;")
head.addWidget(name)
# '추천'은 표에 박힌 값이 아니라 이 PC 기준으로 계산해서 붙인다.
# GPU 가 없는데 10GB 짜리에 추천 배지가 달려 있으면 안내가 아니라 함정이다.
if recommended:
head.addWidget(_badge("CPU 추천" if not gpu.available else "추천", p.accent))
if tier.best_after_finetune:
head.addWidget(_badge("추가학습 최적", p.success))
if gpu_vram_gb and gpu_vram_gb < tier.min_vram_gb:
head.addWidget(_badge(f"VRAM {tier.min_vram_gb:g}GB 필요", p.warning))
if not self.available:
head.addWidget(_badge("사용 불가", p.danger))
head.addStretch(1)
text.addLayout(head)
tagline = QLabel(tier.tagline)
tagline.setObjectName("Subtitle")
text.addWidget(tagline)
spec = QLabel(
f"예상 지연 {tier.approx_latency_s:g}초 · 권장 VRAM {tier.min_vram_gb:g}GB "
f"· 내려받기 약 {tier.download_gb:g}GB"
)
spec.setObjectName("Hint")
text.addWidget(spec)
if tier.notes:
note = QLabel(tier.notes)
note.setObjectName("Hint")
note.setWordWrap(True)
text.addWidget(note)
if not self.available:
# 포터블 exe 에는 torch 가 없어 LLM 티어를 못 쓴다. 눌러도 안 되는
# 이유를 카드에 적어둬야 사용자가 헤매지 않는다.
blocked = QLabel(self.unavailable_reason)
blocked.setStyleSheet(f"color: {p.warning};")
blocked.setWordWrap(True)
text.addWidget(blocked)
self.radio.setEnabled(False)
self.setEnabled(True)
layout.addLayout(text, 1)
def set_selected(self, selected: bool) -> None:
self.setObjectName("CardSelected" if selected else "Card")
self.style().unpolish(self)
self.style().polish(self)
class ModelsPage(QWidget):
tier_changed = Signal(str)
config_changed = Signal()
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
self._cards: list[TierCard] = []
# 티어 카드마다 조회하면 nvidia-smi 를 다섯 번 부른다. 한 번만 본다.
self._gpu = detect_gpu()
self._gpu_vram_gb = self._gpu.total_vram_mb / 1024 if self._gpu.available else 0.0
#: 이 PC 기준 추천 티어. GPU 가 없으면 CPU 에서 실제로 돌아가는 쪽을 권한다.
self._recommended = recommended_tier(self._gpu)
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader(
"모델",
"속도 우선(1)부터 품질 우선(5)까지 다섯 단계입니다. "
"모델은 처음 사용할 때 자동으로 내려받습니다.",
)
)
root.addWidget(self._build_gpu_card())
self._group = QButtonGroup(self)
self._group.setExclusive(True)
for tier in ordered_tiers():
card = TierCard(tier, self._gpu, recommended=tier.key == self._recommended)
self._group.addButton(card.radio, tier.order)
card.radio.toggled.connect(
lambda checked, t=tier: self._on_tier_selected(t) if checked else None
)
self._cards.append(card)
root.addWidget(card)
root.addWidget(self._build_lora_card())
root.addStretch(1)
self._apply_selection(config.models.tier)
# --- 구성 -----------------------------------------------------------
def _build_gpu_card(self) -> Card:
gpu = self._gpu
p = palette("dark")
card = Card("실행 환경")
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
if gpu.available:
text = (
f"GPU: {gpu.name} · VRAM {gpu.total_vram_mb / 1024:.1f}GB "
f"(여유 {gpu.free_vram_mb / 1024:.1f}GB)"
)
color = p.success
else:
text = f"GPU를 쓸 수 없습니다 — {gpu.reason} (CPU로도 동작하지만 많이 느립니다)"
color = p.warning
info = QLabel(text)
info.setStyleSheet(f"color: {color}; font-weight: 600;")
info.setWordWrap(True)
layout.addWidget(info, 1)
self.device_combo = QComboBox()
self.device_combo.addItem("GPU (CUDA)", "cuda")
self.device_combo.addItem("CPU", "cpu")
# GPU 가 없으면 콤보를 비활성화만 하고 "GPU (CUDA)" 를 그대로 띄워두면
# 실제로는 CPU 로 도는데 화면은 GPU 라고 말하는 상태가 된다. 맞춰둔다.
if not gpu.available:
self.config.models.device = "cpu"
self.device_combo.setCurrentIndex(
max(0, self.device_combo.findData(self.config.models.device))
)
self.device_combo.setEnabled(gpu.available)
self.device_combo.currentIndexChanged.connect(self._on_device_changed)
layout.addWidget(self.device_combo)
card.add(row)
if not gpu.available:
card.add(self._build_cpu_advice_row())
return card
def _build_cpu_advice_row(self) -> QWidget:
"""GPU 를 못 찾았을 때 CPU 에서 실제로 쓸 만한 설정을 권한다."""
tier = get_tier(self._recommended)
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
advice = QLabel(
f"CPU 에서는 '{tier.order}. {tier.name}' 를 권합니다 — "
f"CPU 로 실시간 자막이 따라오는 가장 무거운 구성입니다. "
f"상위 티어는 한 문장에 수십 초가 걸려 실시간으로 쓸 수 없습니다."
)
advice.setObjectName("Hint")
advice.setWordWrap(True)
layout.addWidget(advice, 1)
apply_button = QPushButton("추천대로 맞추기")
apply_button.clicked.connect(self._apply_recommended)
layout.addWidget(apply_button)
return row
def _apply_recommended(self) -> None:
self.config.models.device = "cpu"
self.device_combo.blockSignals(True)
self.device_combo.setCurrentIndex(max(0, self.device_combo.findData("cpu")))
self.device_combo.blockSignals(False)
self._apply_selection(self._recommended)
self._on_tier_selected(get_tier(self._recommended))
def _build_lora_card(self) -> Card:
card = Card(
"추가학습 어댑터 (선택)",
"게임·방송 용어로 따로 학습시킨 LoRA 어댑터 폴더를 지정하면 번역 모델에 얹습니다. "
"scripts/finetune_mt.py 로 만들 수 있습니다.",
)
row = QWidget()
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self.lora_edit = QLineEdit(self.config.models.lora_adapter_path)
self.lora_edit.setPlaceholderText("비워두면 사용하지 않습니다")
self.lora_edit.editingFinished.connect(self._on_lora_changed)
layout.addWidget(self.lora_edit, 1)
browse = QPushButton("폴더 선택")
browse.clicked.connect(self._browse_lora)
layout.addWidget(browse)
card.add(row)
return card
# --- 동작 -----------------------------------------------------------
def _apply_selection(self, tier_key: str) -> None:
for card in self._cards:
selected = card.tier.key == tier_key
card.radio.setChecked(selected)
card.set_selected(selected)
def _on_tier_selected(self, tier: Tier) -> None:
if self.config.models.tier == tier.key:
return
self.config.models.tier = tier.key
for card in self._cards:
card.set_selected(card.tier.key == tier.key)
self.tier_changed.emit(tier.key)
self.config_changed.emit()
def _on_device_changed(self) -> None:
self.config.models.device = self.device_combo.currentData()
self.config_changed.emit()
def _on_lora_changed(self) -> None:
self.config.models.lora_adapter_path = self.lora_edit.text().strip()
self.config_changed.emit()
def _browse_lora(self) -> None:
from PySide6.QtWidgets import QFileDialog
path = QFileDialog.getExistingDirectory(self, "LoRA 어댑터 폴더 선택")
if path:
self.lora_edit.setText(path)
self._on_lora_changed()
def _badge(text: str, color: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet(
f"color: {color}; border: 1px solid {color}; border-radius: 9px;"
f"padding: 1px 8px; font-size: 11px; font-weight: 700;"
)
return label

View File

@@ -0,0 +1,368 @@
"""설정 — 인식 민감도, 응답 속도, 기타 동작."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
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 ...storage import free_space, human_size
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_storage_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)
return card
# --- 저장 위치 -------------------------------------------------------
def _build_storage_card(self) -> Card:
card = Card(
"저장 위치",
"모델과 설정, 번역 기록이 여기에 저장됩니다. 모델은 품질 티어에 따라 "
"2~16GB 를 차지하므로 여유 있는 디스크가 좋습니다.",
)
self.storage_label = QLabel()
self.storage_label.setObjectName("Hint")
self.storage_label.setWordWrap(True)
self.storage_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
card.add(self.storage_label)
row = QWidget()
row_layout = QHBoxLayout(row)
row_layout.setContentsMargins(0, 0, 0, 0)
change = QPushButton("위치 변경…")
change.clicked.connect(self._change_storage)
open_folder = QPushButton("폴더 열기")
open_folder.clicked.connect(self._open_folder)
row_layout.addWidget(change)
row_layout.addWidget(open_folder)
row_layout.addStretch(1)
card.add(row)
self._refresh_storage_label()
return card
def _refresh_storage_label(self) -> None:
path = user_data_dir()
self.storage_label.setText(f"{path}\n이 디스크의 남은 공간 {human_size(free_space(path))}")
def _change_storage(self) -> None:
from ..data_location import ask_change_location
target = ask_change_location(self)
if target is None:
return
# 옮기지 않기를 골랐다면 새 위치에 설정 파일이 없다. 지금 상태를 써둔다.
self.config.save()
self._refresh_storage_label()
QMessageBox.information(
self,
"저장 위치를 바꿨습니다",
f"이제 여기를 씁니다.\n\n{target}\n\n"
"이미 메모리에 올라가 있는 모델은 그대로 쓰고, 다음에 받는 것부터 "
"새 위치에 저장됩니다. 로그 파일은 다음 실행부터 옮겨집니다.",
)
def _build_about_card(self) -> Card:
return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN)
# --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None:
cfg = self.config.audio
cfg.input_gain = self.gain_slider.value() / 100
cfg.vad_aggressiveness = self.vad_combo.currentData()
cfg.silence_ms = self.silence_spin.value()
cfg.max_segment_ms = self.max_spin.value()
cfg.partial_interval_ms = self.partial_spin.value()
self.config_changed.emit()
self.restart_needed.emit()
def _on_behavior_changed(self, *_) -> None:
self.config.theme = self.theme_combo.currentData()
self.config.models.preload_on_start = self.preload_check.isChecked()
self.config.log_transcripts = self.log_check.isChecked()
self.config_changed.emit()
def _open_folder(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_data_dir())))
def _spin(value, low, high, step, suffix, slot) -> QSpinBox:
spin = QSpinBox()
spin.setRange(low, high)
spin.setSingleStep(step)
spin.setSuffix(suffix)
spin.setValue(value)
spin.valueChanged.connect(slot)
return spin
def _slider_row(value, low, high, suffix, slot) -> tuple[QSlider, QWidget]:
row = QWidget()
row.setObjectName("Row")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
slider = QSlider(Qt.Orientation.Horizontal)
slider.setRange(low, high)
slider.setValue(value)
label = QLabel(f"{value}{suffix}")
label.setMinimumWidth(48)
slider.valueChanged.connect(lambda v: label.setText(f"{v}{suffix}"))
slider.valueChanged.connect(slot)
layout.addWidget(slider, 1)
layout.addWidget(label)
return slider, row

View File

@@ -0,0 +1,420 @@
"""자막 — 글꼴·색·크기 등 표시 설정과 실시간 미리보기."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QFontComboBox,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...config import AppConfig
from ..overlay import SubtitlePreview
from ..placement import ANCHOR_CUSTOM, ANCHOR_LABELS, list_screens
from ..theme import SPACING
from ..widgets import AnchorGrid, Card, PageHeader
PREVIEW_TEXT = "적이 왼쪽에서 들어온다, 지금 바로 빠져!"
PREVIEW_SOURCE = "Enemy coming from the left, fall back now!"
class ColorButton(QPushButton):
color_picked = Signal(str)
def __init__(self, color: str, parent: QWidget | None = None):
super().__init__(parent)
self.setFixedSize(52, 30)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_color(color)
self.clicked.connect(self._pick)
@property
def color(self) -> str:
return self._color
def set_color(self, color: str) -> None:
self._color = color
self.setStyleSheet(
f"background: {color}; border: 1px solid #3A4152; border-radius: 6px;"
)
def _pick(self) -> None:
chosen = QColorDialog.getColor(QColor(self._color), self, "색 선택")
if chosen.isValid():
self.set_color(chosen.name())
self.color_picked.emit(chosen.name())
class SubtitlePage(QWidget):
style_changed = Signal()
#: 자막 표시 on/off
overlay_toggled = Signal(bool)
#: 자막을 띄울 모니터 / 9분할 위치가 바뀜
placement_changed = Signal()
#: '자막 위치 조정' 버튼 — 오버레이를 조정 가능한 상태로 열고 닫는다
edit_mode_requested = Signal(bool)
def __init__(self, config: AppConfig, parent: QWidget | None = None):
super().__init__(parent)
self.config = config
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(SPACING + 4)
root.addWidget(
PageHeader("자막", "자막을 켜고 끄고, 어느 모니터 어디에 띄울지 정합니다.")
)
root.addWidget(self._build_display_card())
root.addWidget(self._build_preview_card())
root.addWidget(self._build_text_card())
root.addWidget(self._build_layout_card())
root.addStretch(1)
self._sync_preview()
# --- 구성 -----------------------------------------------------------
def _build_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)
# --- 직접 옮기기 ---
# 자막 창은 기본적으로 잠겨 있다. 게임 중에 잘못 건드려 끌고 다니는
# 사고를 막기 위해서다. 옮기거나 크기를 바꿀 때만 여기서 연다.
adjust_row = QWidget()
adjust_layout = QHBoxLayout(adjust_row)
adjust_layout.setContentsMargins(0, 0, 0, 0)
adjust_layout.setSpacing(10)
self.adjust_button = QPushButton("자막 위치 조정")
self.adjust_button.setCheckable(True)
self.adjust_button.setMinimumWidth(150)
self.adjust_button.toggled.connect(self.edit_mode_requested.emit)
adjust_layout.addWidget(self.adjust_button)
self.adjust_hint = QLabel()
self.adjust_hint.setObjectName("Hint")
self.adjust_hint.setWordWrap(True)
adjust_layout.addWidget(self.adjust_hint, 1)
card.add(adjust_row)
self.set_edit_mode(False)
self.refresh_screens()
return card
def set_edit_mode(self, on: bool) -> None:
"""오버레이의 위치 조정 상태를 버튼과 안내에 반영한다."""
self.adjust_button.blockSignals(True)
self.adjust_button.setChecked(on)
self.adjust_button.blockSignals(False)
self.adjust_button.setText("조정 끝내기" if on else "자막 위치 조정")
self.adjust_button.setObjectName("Danger" if on else "")
self.adjust_button.style().polish(self.adjust_button)
self.adjust_hint.setText(
"자막 창을 끌어서 옮기고, 네 모서리를 잡아 크기를 바꾸세요. "
"크기를 바꾸면 글자 크기도 같이 따라옵니다."
if on
else "평소에는 자막이 잠겨 있어 게임 중에 실수로 움직이지 않습니다."
)
def set_font_size(self, size: int) -> None:
"""자막 창에서 크기를 바꿨을 때 설정 화면 숫자를 맞춘다."""
if size == self.size_spin.value():
return
self.size_spin.blockSignals(True)
self.size_spin.setValue(size)
self.size_spin.blockSignals(False)
self.config.subtitle.font_size = size
self._sync_preview()
def _build_preview_card(self) -> Card:
card = Card("미리보기", "실제 자막 창과 같은 방식으로 그립니다.")
self.preview = SubtitlePreview(self.config.subtitle)
self.preview.set_sample(PREVIEW_TEXT, PREVIEW_SOURCE)
card.add(self.preview)
return card
def _build_text_card(self) -> Card:
st = self.config.subtitle
card = Card("글씨")
form = card.add_form()
self.font_combo = QFontComboBox()
available = set(QFontDatabase.families())
self.font_combo.setCurrentFont(
QFont(st.font_family if st.font_family in available else "Segoe UI")
)
self.font_combo.currentFontChanged.connect(self._on_font_changed)
form.addRow("글꼴", self.font_combo)
self.size_spin = QSpinBox()
self.size_spin.setRange(12, 120)
self.size_spin.setSuffix(" px")
self.size_spin.setValue(st.font_size)
self.size_spin.valueChanged.connect(self._on_change)
form.addRow("크기", self.size_spin)
self.bold_check = QCheckBox("굵게")
self.bold_check.setChecked(st.bold)
self.bold_check.toggled.connect(self._on_change)
form.addRow("", self.bold_check)
self.text_color = ColorButton(st.text_color)
self.text_color.color_picked.connect(self._on_change)
form.addRow("번역문 색", self.text_color)
self.source_color = ColorButton(st.source_text_color)
self.source_color.color_picked.connect(self._on_change)
form.addRow("원문 색", self.source_color)
self.outline_color = ColorButton(st.outline_color)
self.outline_color.color_picked.connect(self._on_change)
form.addRow("외곽선 색", self.outline_color)
self.outline_spin = QSpinBox()
self.outline_spin.setRange(0, 8)
self.outline_spin.setSuffix(" px")
self.outline_spin.setValue(st.outline_width)
self.outline_spin.setToolTip("배경이 밝든 어둡든 글씨가 읽히게 해줍니다. 0이면 끕니다.")
self.outline_spin.valueChanged.connect(self._on_change)
form.addRow("외곽선 두께", self.outline_spin)
return card
def _build_layout_card(self) -> Card:
st = self.config.subtitle
card = Card("배치와 배경")
form = card.add_form()
self.bg_color = ColorButton(st.background_color)
self.bg_color.color_picked.connect(self._on_change)
form.addRow("배경 색", self.bg_color)
opacity_row = QWidget()
opacity_row.setObjectName("Row")
layout = QHBoxLayout(opacity_row)
layout.setContentsMargins(0, 0, 0, 0)
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(0, 100)
self.opacity_slider.setValue(st.background_opacity)
self.opacity_slider.valueChanged.connect(self._on_change)
self.opacity_value = QLabel(f"{st.background_opacity}%")
self.opacity_value.setMinimumWidth(42)
layout.addWidget(self.opacity_slider, 1)
layout.addWidget(self.opacity_value)
form.addRow("배경 투명도", opacity_row)
self.padding_spin = QSpinBox()
self.padding_spin.setRange(0, 80)
self.padding_spin.setSuffix(" px")
self.padding_spin.setValue(st.padding)
self.padding_spin.setToolTip(
"배경 상자는 글자를 감싸는 크기로 그려집니다. 이 값이 글자와 상자 "
"가장자리 사이 간격, 곧 배경 크기를 정합니다."
)
self.padding_spin.valueChanged.connect(self._on_change)
form.addRow("배경 여백", self.padding_spin)
self.align_combo = QComboBox()
for label, value in (("가운데", "center"), ("왼쪽", "left"), ("오른쪽", "right")):
self.align_combo.addItem(label, value)
self.align_combo.setCurrentIndex(max(0, self.align_combo.findData(st.align)))
self.align_combo.currentIndexChanged.connect(self._on_change)
form.addRow("정렬", self.align_combo)
self.lines_spin = QSpinBox()
self.lines_spin.setRange(1, 5)
self.lines_spin.setSuffix(" 줄")
self.lines_spin.setValue(st.max_lines)
self.lines_spin.valueChanged.connect(self._on_change)
form.addRow("최대 줄 수", self.lines_spin)
self.fade_spin = QSpinBox()
self.fade_spin.setRange(0, 30_000)
self.fade_spin.setSingleStep(500)
self.fade_spin.setSuffix(" ms")
self.fade_spin.setValue(st.fade_out_ms)
self.fade_spin.setToolTip("이 시간이 지나면 자막을 지웁니다. 0이면 계속 남겨둡니다.")
self.fade_spin.valueChanged.connect(self._on_change)
form.addRow("자동 지우기", self.fade_spin)
self.show_source_check = QCheckBox("원문도 같이 표시")
self.show_source_check.setChecked(st.show_source)
self.show_source_check.toggled.connect(self._on_change)
form.addRow("", self.show_source_check)
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()
self._on_change()
def _on_change(self, *_) -> None:
st = self.config.subtitle
st.font_size = self.size_spin.value()
st.bold = self.bold_check.isChecked()
st.text_color = self.text_color.color
st.source_text_color = self.source_color.color
st.outline_color = self.outline_color.color
st.outline_width = self.outline_spin.value()
st.background_color = self.bg_color.color
st.background_opacity = self.opacity_slider.value()
st.padding = self.padding_spin.value()
st.align = self.align_combo.currentData()
st.max_lines = self.lines_spin.value()
st.fade_out_ms = self.fade_spin.value()
st.show_source = self.show_source_check.isChecked()
self.opacity_value.setText(f"{st.background_opacity}%")
self._sync_preview()
self.style_changed.emit()
def _sync_preview(self) -> None:
# 미리보기는 설정 객체를 그대로 참조하므로 다시 그리기만 하면 된다.
self.preview.setMinimumHeight(max(140, self.config.subtitle.font_size * 4))
self.preview.update()

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

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

210
src/livesub/ui/theme.py Normal file
View File

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

View File

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

View File

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

View File

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

BIN
tests/fixtures/en_callout_16k.wav vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,75 @@
"""캡처 대상 목록 — 출력(게임 소리)과 입력(마이크) 전환."""
from __future__ import annotations
import pytest
from livesub import audio
from livesub.audio import (
SOURCE_MODE_INPUT,
SOURCE_MODE_OUTPUT,
AudioSource,
CaptureError,
InputDeviceCapture,
create_capture,
list_sources,
)
from livesub.config import AudioConfig
MIC = AudioSource(kind="input", identifier="3", label="마이크 (Realtek)", detail="기본 입력 장치")
SPEAKER = AudioSource(kind="device", identifier="7", label="스피커", detail="기본 출력 장치")
GAME = AudioSource(kind="process", identifier="4242", label="Overwatch.exe")
@pytest.fixture
def fake_devices(monkeypatch):
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: True))
monkeypatch.setattr(InputDeviceCapture, "list_sources", staticmethod(lambda: [MIC]))
monkeypatch.setattr(
audio.WasapiLoopbackCapture, "list_sources", staticmethod(lambda: [SPEAKER])
)
monkeypatch.setattr(
audio.ProcessLoopbackCapture, "available", staticmethod(lambda: True)
)
monkeypatch.setattr(
audio.ProcessLoopbackCapture, "list_sources", staticmethod(lambda: [GAME])
)
def test_기본은_프로그램과_출력_장치를_보여준다(fake_devices):
kinds = [s.kind for s in list_sources()]
assert kinds == ["process", "device"]
assert list_sources() == list_sources(SOURCE_MODE_OUTPUT)
def test_입력_모드는_마이크만_보여준다(fake_devices):
"""두 목록을 섞으면 찾으려던 게임이 라인입력 사이에 파묻힌다."""
sources = list_sources(SOURCE_MODE_INPUT)
assert [s.kind for s in sources] == ["input"]
assert sources[0].label == "마이크 (Realtek)"
def test_입력_장치도_장치_번호를_준다():
assert MIC.device_index == 3
assert MIC.pid == 0
def test_입력_백엔드로_캡처를_만든다(fake_devices):
cfg = AudioConfig(backend="input", device_index=3)
capture = create_capture(cfg)
assert isinstance(capture, InputDeviceCapture)
assert capture.device_index == 3
def test_입력_장치를_못_열면_이유를_알려준다(monkeypatch):
monkeypatch.setattr(InputDeviceCapture, "available", staticmethod(lambda: False))
with pytest.raises(CaptureError, match="입력 장치"):
create_capture(AudioConfig(backend="input", device_index=3))
def test_입력_가능_여부가_환경_보고에_들어간다():
assert "input" in audio.capture_capabilities()
def test_고른_목록_쪽을_설정에_기억한다():
assert AudioConfig().source_mode == SOURCE_MODE_OUTPUT

Some files were not shown because too many files have changed in this diff Show More