Compare commits
4 Commits
162540b025
...
a66eedee8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66eedee8b | ||
|
|
dbe1370425 | ||
|
|
03a2ca50c0 | ||
|
|
e234ad4401 |
@@ -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,
|
||||
|
||||
25
packaging/pyinstaller-hooks/hook-webrtcvad.py
Normal file
25
packaging/pyinstaller-hooks/hook-webrtcvad.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -5,22 +5,31 @@
|
||||
이 서버는 그 반대편이다.
|
||||
|
||||
GET /ping 게스트가 네트워크 올라왔는지 확인용
|
||||
GET /stage20.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로)
|
||||
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
|
||||
MAX_ARTIFACT = 32 * 1024 * 1024
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
@@ -47,9 +56,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 +72,27 @@ 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
|
||||
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):
|
||||
# 접근 로그는 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 +103,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()
|
||||
|
||||
@@ -211,18 +211,59 @@ Log "=== stage2 완료 ==="
|
||||
# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다.
|
||||
# ==================================================================
|
||||
$Repo = 'C:\win-ci\live-app-translator'
|
||||
function Step($name, $block) {
|
||||
Log "--- [$name] 시작"
|
||||
$OutDir = 'C:\win-ci\out'
|
||||
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 참고).
|
||||
$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 {
|
||||
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
|
||||
@@ -251,16 +292,29 @@ try {
|
||||
# 죽는다. 검증 스크립트가 한글을 찍으므로 UTF-8 로 고정한다.
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
$env:PYTHONUTF8 = '1'
|
||||
$r = @{}
|
||||
$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" } }
|
||||
$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" } }
|
||||
# 출력을 자르지 않는다. 전문은 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" } }
|
||||
$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
|
||||
} catch {
|
||||
Log "검증 준비 단계에서 실패: $($_.Exception.Message)"
|
||||
|
||||
25
scripts/win-ci/sync.sh
Executable file
25
scripts/win-ci/sync.sh
Executable 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초 안에 받아간다)"
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 는 사람 목소리가 아니라고 걸러낸다
|
||||
|
||||
Reference in New Issue
Block a user