11 Commits

Author SHA1 Message Date
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
15 changed files with 460 additions and 46 deletions

7
.gitignore vendored
View File

@@ -10,9 +10,12 @@ venv/
.ruff_cache/
# 네이티브 빌드 산출물
#
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
native/process_loopback/build/
src/hearo/resources/bin/*.exe
src/hearo/resources/bin/*.pdb
src/livesub/resources/bin/*.exe
src/livesub/resources/bin/*.pdb
# 모델 캐시 / 학습 산출물 / 사용자 데이터
#

View File

@@ -175,11 +175,62 @@ scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스
| 방향 | 통로 | 왜 |
|---|---|---|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
| 게스트 → 호스트 | 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 를 다시 내려받습니다.
접속 정보: 계정 `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 를

View File

@@ -49,8 +49,13 @@ BOOL WINAPI ConsoleHandler(DWORD type) {
}
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
//
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
// 같은 형태다.
class ActivationHandler
: public RuntimeClass<RuntimeClassFlags<ClassicCom | FtmBase>,
: public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
IActivateAudioInterfaceCompletionHandler> {
public:
explicit ActivationHandler(HANDLE done) : done_(done) {}

View File

@@ -72,7 +72,8 @@ a = Analysis( # noqa: F821
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
# contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
runtime_hooks=[],
excludes=excludes,
noarchive=False,

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

@@ -43,7 +43,10 @@ finetune = [
"accelerate>=0.33",
"bitsandbytes>=0.43; platform_system != 'Darwin'",
]
dev = ["pytest>=8.0", "ruff>=0.6"]
# 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"

View File

@@ -18,6 +18,24 @@ 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 \
@@ -43,12 +61,7 @@ exec qemu-system-x86_64 \
-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 \
-drive "file=$D/iso/win11-ltsc.iso,if=none,id=cdwin,media=cdrom,readonly=on" \
-device ide-cd,drive=cdwin,bus=ide.1,bootindex=1 \
-drive "file=$D/iso/virtio-win.iso,if=none,id=cdvirtio,media=cdrom,readonly=on" \
-device ide-cd,drive=cdvirtio,bus=ide.2 \
-drive "file=$D/unattend.iso,if=none,id=cdunattend,media=cdrom,readonly=on" \
-device ide-cd,drive=cdunattend,bus=ide.3 \
"${CDROMS[@]}" \
-serial "file:$D/serial.log" \
-qmp "unix:$D/qmp.sock,server=on,wait=off" \
-vnc 127.0.0.1:19 \

View File

@@ -4,23 +4,35 @@
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append
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):
@@ -47,9 +59,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().do_GET()
def do_POST(self):
if self.path.rstrip("/") != "/log":
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)
return
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:
@@ -57,9 +75,44 @@ class Handler(http.server.SimpleHTTPRequestHandler):
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("%s %s\n" % (self.address_string(), fmt % args))
sys.stdout.write(f"{self.address_string()} {fmt % args}\n")
sys.stdout.flush()
@@ -70,6 +123,7 @@ class Server(socketserver.ThreadingTCPServer):
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

@@ -10,6 +10,14 @@
$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
@@ -105,6 +113,41 @@ if (Have 'cmake') {
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'
@@ -211,28 +254,80 @@ Log "=== stage2 완료 ==="
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ==================================================================
$Repo = 'C:\win-ci\live-app-translator'
function Step($name, $block) {
Log "--- [$name] 시작"
$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 {
& $block
Log "--- [$name] 통과"
return $true
$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)"
return $false
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 (Test-Path $Repo) {
Log "저장소 갱신"
Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location
} else {
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'
@@ -247,15 +342,69 @@ try {
Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)"
$env:PYTHONPATH = 'src'
$r = @{}
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" }
$r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
# 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" } }
Log "=== Windows 검증 결과 ==="
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) }
# 캡처 도우미를 먼저 만든다. 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" } }
$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 {

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초 안에 받아간다)"

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

View File

@@ -3,6 +3,13 @@
from .asr import SpeechRecognizer, Transcript
from .glossary import Glossary, GlossaryEntry
from .manager import GpuInfo, ModelManager, detect_gpu
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .tiers import (
DEFAULT_TIER,
TIERS,
@@ -12,13 +19,6 @@ from .tiers import (
get_tier,
ordered_tiers,
)
from .speech_level import (
Politeness,
SpeechLevel,
apply_speech_level,
detect_politeness,
to_casual,
)
from .translator import Translator, build_seedx_prompt, create_translator
__all__ = [

View File

@@ -64,7 +64,7 @@ class Glossary:
# --- 영속화 --------------------------------------------------------
@classmethod
def load(cls, path: str | Path, case_sensitive: bool = False) -> "Glossary":
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)

View File

@@ -1,8 +1,30 @@
import numpy as np
import pytest
from livesub.audio import segmenter as segmenter_mod
from livesub.audio.segmenter import Segmenter, SegmenterConfig
from livesub.constants import SAMPLE_RATE
#: webrtcvad 는 선택 의존성이다. 있으면 아래 게이트 테스트가 돌고 없으면 skip 된다.
REAL_WEBRTCVAD = segmenter_mod.webrtcvad
@pytest.fixture(autouse=True)
def _energy_vad_only(monkeypatch):
"""이 파일의 테스트는 에너지 VAD 경로로 고정한다.
입력이 220Hz 순음이기 때문이다. webrtcvad 는 사람 목소리로 학습돼 있어
순음을 발화로 보지 않고, `_is_speech` 는 에너지 판정과 webrtcvad 를 AND 로
묶는다. 그래서 webrtcvad 가 깔린 환경에서는 이 파일의 테스트 5개가 깨졌다 —
포터블 exe 의 requirements 에 webrtcvad-wheels 가 들어 있어 Windows CI 가
정확히 그 환경이고, 개발용 venv 에는 없어서 여태 안 드러났다.
고칠 대상은 판정기가 아니라 테스트의 숨은 전제다. 여기서 보려는 건 분할
상태 기계이므로 선택 의존성을 끊어 환경과 무관하게 같은 경로를 시험한다.
게이트 동작 자체는 test_webrtcvad_gate_filters_pure_tone 이 본다.
"""
monkeypatch.setattr(segmenter_mod, "webrtcvad", None)
def tone(ms: int, amplitude: float = 0.3) -> np.ndarray:
n = SAMPLE_RATE * ms // 1000
@@ -76,3 +98,24 @@ def test_lead_in_is_prepended():
assert len(finals) == 1
# 0.6초 발화 + 최대 0.2초 리드인
assert finals[0].duration_s > 0.6
@pytest.mark.skipif(REAL_WEBRTCVAD is None, reason="webrtcvad 미설치 — 게이트 경로가 없다")
def test_webrtcvad_gate_filters_pure_tone(monkeypatch):
"""webrtcvad 가 깔리면 순음은 에너지 VAD 만일 때보다 덜 통과한다.
포터블 exe 와 Windows CI 가 이 경로를 쓴다. 두 경로가 실제로 다르게
동작한다는 사실을 테스트로 고정해 둔다 — 이게 깨져 있는 걸 몰라서
같은 테스트가 플랫폼에 따라 다른 결과를 냈다.
"""
energy_only = Segmenter(config())
energy_out = energy_only.push(tone(800)) + energy_only.push(silence(600))
monkeypatch.setattr(segmenter_mod, "webrtcvad", REAL_WEBRTCVAD)
gated = Segmenter(config())
gated_out = gated.push(tone(800)) + gated.push(silence(600))
energy_total = sum(s.duration_s for s in energy_out if s.is_final)
gated_total = sum(s.duration_s for s in gated_out if s.is_final)
assert energy_total > 0.7 # 에너지 VAD 는 순음을 발화로 본다
assert gated_total < energy_total # webrtcvad 는 사람 목소리가 아니라고 걸러낸다