4 Commits

Author SHA1 Message Date
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
9 changed files with 218 additions and 33 deletions

View File

@@ -72,7 +72,8 @@ a = Analysis( # noqa: F821
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
hookspath=[], # contrib 훅이 빌드를 중단시키는 것을 덮어쓴다 (hook-webrtcvad.py 주석 참고).
hookspath=[str(ROOT / "packaging" / "pyinstaller-hooks")],
runtime_hooks=[], runtime_hooks=[],
excludes=excludes, excludes=excludes,
noarchive=False, 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", "accelerate>=0.33",
"bitsandbytes>=0.43; platform_system != 'Darwin'", "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] [project.scripts]
livesub = "livesub.app:main" livesub = "livesub.app:main"

View File

@@ -4,23 +4,32 @@
게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다. 게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다.
이 서버는 그 반대편이다. 이 서버는 그 반대편이다.
GET /ping 게스트가 네트워크 올라왔는지 확인용 GET /ping 게스트가 네트워크 올라왔는지 확인용
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등) GET /<file> serve/ 안의 아무 파일 (러너 등록 토큰 등)
POST /log 게스트 로그 -> guest.log 에 append 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 는 호스트 루프백으로 들어오므로 127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로
외부에 열 필요가 없다. 외부에 열 필요가 없다.
""" """
import http.server import http.server
import os import os
import posixpath
import socketserver import socketserver
import sys import sys
from datetime import datetime from datetime import datetime
ROOT = "/home/claude/win-ci/serve" ROOT = "/home/claude/win-ci/serve"
LOG = "/home/claude/win-ci/guest.log" LOG = "/home/claude/win-ci/guest.log"
RESULTS = "/home/claude/win-ci/results"
PORT = 8099 PORT = 8099
MAX_ARTIFACT = 32 * 1024 * 1024
class Handler(http.server.SimpleHTTPRequestHandler): class Handler(http.server.SimpleHTTPRequestHandler):
@@ -47,9 +56,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
super().do_GET() super().do_GET()
def do_POST(self): 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) self.send_error(404)
return
def _recv_log(self):
n = int(self.headers.get("Content-Length") or 0) n = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(n).decode("utf-8", "replace").strip() data = self.rfile.read(n).decode("utf-8", "replace").strip()
with open(LOG, "a", encoding="utf-8") as f: with open(LOG, "a", encoding="utf-8") as f:
@@ -57,9 +72,27 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.send_response(204) self.send_response(204)
self.end_headers() 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
data = self.rfile.read(n)
os.makedirs(RESULTS, exist_ok=True)
with open(os.path.join(RESULTS, name), "wb") as f:
f.write(data)
print(f"artifact {name} {len(data)} bytes", flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
# 접근 로그는 stdout 으로만. journald 가 받는다. # 접근 로그는 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() sys.stdout.flush()
@@ -70,6 +103,7 @@ class Server(socketserver.ThreadingTCPServer):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs(ROOT, exist_ok=True) os.makedirs(ROOT, exist_ok=True)
os.makedirs(RESULTS, exist_ok=True)
with Server(("127.0.0.1", PORT), Handler) as httpd: 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) print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True)
httpd.serve_forever() httpd.serve_forever()

View File

@@ -211,18 +211,59 @@ Log "=== stage2 완료 ==="
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. # agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
# ================================================================== # ==================================================================
$Repo = 'C:\win-ci\live-app-translator' $Repo = 'C:\win-ci\live-app-translator'
function Step($name, $block) { $OutDir = 'C:\win-ci\out'
Log "--- [$name] 시작" 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 { try {
& $block $bytes = [IO.File]::ReadAllBytes($path)
Log "--- [$name] 통과" # 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
return $true # 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 120; Body = $bytes; ContentType = 'application/octet-stream' }
Invoke-RestMethod @req | Out-Null
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
} catch { } catch {
Log "--- [$name] 실패: $($_.Exception.Message)" Log " -> $name 업로드 실패: $($_.Exception.Message)"
return $false
} }
} }
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' $ErrorActionPreference = 'Continue'
try { try {
Sync-Path Sync-Path
@@ -251,16 +292,29 @@ try {
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다. # 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
$env:PYTHONIOENCODING = 'utf-8' $env:PYTHONIOENCODING = 'utf-8'
$env:PYTHONUTF8 = '1' $env:PYTHONUTF8 = '1'
$r = @{} # 출력을 자르지 않는다. 전문은 Step 이 호스트 results/<name>.log 로 올린다.
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 30 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } # 여기서 truncate 하면 실패 원인이 있는 트레이스백 본문이 날아간다.
$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 = [ordered]@{}
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; 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['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
$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" } } $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; & $py -m PyInstaller packaging\livesub.spec --noconfirm; 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" } }
Log "=== Windows 검증 결과 ===" # 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) } # 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
Log "=== 검증 끝 (rev2: .gitignore 수정 반영) ===" $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 Pop-Location
} catch { } catch {
Log "검증 준비 단계에서 실패: $($_.Exception.Message)" Log "검증 준비 단계에서 실패: $($_.Exception.Message)"

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

@@ -0,0 +1,25 @@
#!/bin/bash
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
#
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
#
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다.
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
set -euo pipefail
D="${WIN_CI_DIR:-/home/claude/win-ci}"
HERE="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$D/serve" "$D/results"
cp "$HERE/serve/stage2.ps1" "$D/serve/"
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

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

View File

@@ -64,7 +64,7 @@ class Glossary:
# --- 영속화 -------------------------------------------------------- # --- 영속화 --------------------------------------------------------
@classmethod @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) p = Path(path)
if not p.is_file(): if not p.is_file():
return cls(case_sensitive=case_sensitive) return cls(case_sensitive=case_sensitive)

View File

@@ -1,8 +1,30 @@
import numpy as np import numpy as np
import pytest
from livesub.audio import segmenter as segmenter_mod
from livesub.audio.segmenter import Segmenter, SegmenterConfig from livesub.audio.segmenter import Segmenter, SegmenterConfig
from livesub.constants import SAMPLE_RATE 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: def tone(ms: int, amplitude: float = 0.3) -> np.ndarray:
n = SAMPLE_RATE * ms // 1000 n = SAMPLE_RATE * ms // 1000
@@ -76,3 +98,24 @@ def test_lead_in_is_prepended():
assert len(finals) == 1 assert len(finals) == 1
# 0.6초 발화 + 최대 0.2초 리드인 # 0.6초 발화 + 최대 0.2초 리드인
assert finals[0].duration_s > 0.6 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 는 사람 목소리가 아니라고 걸러낸다