로컬 whisper STT 사이드카 디바이스 폴백과 deprecated audioop 제거

cuDNN 부재 환경에서 ctranslate2 는 Python 예외가 아니라 네이티브 크래시로 죽어
프로세스가 통째로 사라진다. 같은 프로세스의 try/except 로는 절대 잡을 수 없어
디바이스 확인을 버릴 수 있는 자식 프로세스(--self-check)로 분리했다.
auto 는 CUDA 프로브 실패 시 CPU(int8)로 폴백하고, 명시적 --device cuda 는 조용히
강등하지 않는다.

함께 고친 것:
- warmup PCM 생성의 연산자 우선순위 버그(b"\x00\x00" * N // 2 가 바이트열 정수
  나눗셈이 되어 TypeError). warmup_pcm() 으로 분리하고 테스트로 고정했다.
- audioop 은 Python 3.13 에서 제거되므로 array + 정수 연산으로 RMS 를 직접 구한다.

실측(저장소 무참조 synthetic seed 8.72초, CPU int8 small):
interim 9 · final 5 · word timestamp 12개 present, 전사는 원문과 한 글자 차이.
사이드카 회귀 37/37.
This commit is contained in:
Yun Chan 2026-08-08 01:39:58 +09:00
parent 16e791e044
commit 05aa7b312e
9 changed files with 252 additions and 30 deletions

View file

@ -29,6 +29,7 @@ import math
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterable, Protocol
from urllib.parse import parse_qs, urlsplit
@ -447,31 +448,66 @@ def resolve_device(requested: str, *, cuda_available: bool | None = None) -> tup
return ("cuda", "float16") if cuda_available else ("cpu", "int8")
def probe_device(model: str, device: str, *, python: str | None = None) -> bool:
"""별도 프로세스에서 디바이스를 실제로 한 번 돌려 본다.
cuDNN 없으면 ctranslate2 Python 예외가 아니라 **네이티브 크래시**
죽는다. 같은 프로세스의 try/except 로는 절대 잡을 없으므로, 살릴 없는
실패는 버릴 있는 자식 프로세스에서 먼저 확인한다.
"""
import subprocess
argv = [
python or sys.executable,
"-X",
"utf8",
str(Path(__file__).resolve()),
"--self-check",
"--model",
model,
"--device",
device,
]
try:
completed = subprocess.run(
argv,
check=False,
capture_output=True,
timeout=600,
shell=False,
)
except (OSError, subprocess.TimeoutExpired):
return False
return completed.returncode == 0
def build_transcriber(
model: str,
requested_device: str,
*,
factory: Callable[[str, str, str], Any],
cuda_available: bool | None = None,
prober: Callable[[str, str], bool] | None = None,
) -> Any:
"""디바이스를 해석하고 warmup 까지 통과한 transcriber 만 돌려준다.
"""디바이스를 해석하고 실제로 추론이 되는 transcriber 만 돌려준다.
`auto` CUDA 보이면 먼저 시도하되, 추론이 실패하면 CPU 내려간다.
명시적 `--device cuda` 조용히 강등하지 않고 그대로 실패시킨다.
`auto` CUDA 보이면 먼저 자식 프로세스로 확인하고, 거기서 실패하면
CPU 내려간다. 명시적 `--device cuda` 조용히 강등하지 .
"""
device, compute_type = resolve_device(requested_device, cuda_available=cuda_available)
check = prober if prober is not None else (lambda m, d: probe_device(m, d))
if device == "cuda" and not check(model, "cuda"):
if requested_device != "auto":
raise TranscriptionError("device_unusable:cuda")
device, compute_type = "cpu", "int8"
try:
transcriber = factory(model, device, compute_type)
transcriber.warmup()
return transcriber
except Exception as exc:
if requested_device != "auto" or device == "cpu":
raise TranscriptionError(f"device_unusable:{device}") from exc
device, compute_type = "cpu", "int8"
transcriber = factory(model, device, compute_type)
transcriber.warmup()
return transcriber
raise TranscriptionError(f"device_unusable:{device}") from exc
async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진입점
@ -573,11 +609,34 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--model", default=DEFAULT_MODEL, choices=list(ALLOWED_MODELS))
parser.add_argument("--device", default="auto", choices=["auto", "cuda", "cpu"])
parser.add_argument("--enable", action="store_true")
parser.add_argument(
"--self-check",
action="store_true",
help="load the model on --device once and exit; used as a crash-safe probe",
)
return parser
def run_self_check(args: argparse.Namespace) -> int: # pragma: no cover - 서브프로세스
device, compute_type = resolve_device(
"cpu" if args.device == "cpu" else args.device
)
transcriber = FasterWhisperTranscriber(
args.model, device=device, compute_type=compute_type
)
transcriber.warmup()
print(json.dumps({"ok": True, "device": device}, separators=(",", ":")))
return 0
def main(argv: Iterable[str] | None = None) -> int: # pragma: no cover - CLI
args = build_parser().parse_args(list(argv) if argv is not None else None)
if args.self_check:
try:
return run_self_check(args)
except Exception as exc:
print(json.dumps({"ok": False, "error": type(exc).__name__}), file=sys.stderr)
return 3
if not args.enable:
print("local whisper STT server is disabled; pass --enable", file=sys.stderr)
return 2

View file

@ -326,42 +326,72 @@ class _FakeTranscriberFactory:
class TranscriberBuildTest(unittest.TestCase):
def test_auto_falls_back_to_cpu_when_cuda_warmup_fails(self) -> None:
"""cuDNN 부재처럼 '장치는 보이지만 추론이 죽는' 경우를 잡는다."""
def test_auto_falls_back_to_cpu_when_the_cuda_probe_fails(self) -> None:
"""cuDNN 부재는 네이티브 크래시라 자식 프로세스 프로브로만 잡힌다."""
factory = _FakeTranscriberFactory()
probes: list[tuple[str, str]] = []
def prober(model: str, device: str) -> bool:
probes.append((model, device))
return False
factory = _FakeTranscriberFactory(failing_device="cuda")
transcriber = MODULE.build_transcriber(
"small", "auto", factory=factory, cuda_available=True
"small", "auto", factory=factory, cuda_available=True, prober=prober
)
self.assertEqual(probes, [("small", "cuda")])
self.assertEqual(transcriber.device, "cpu")
self.assertEqual(transcriber.compute_type, "int8")
self.assertEqual(
factory.built, [("small", "cuda", "float16"), ("small", "cpu", "int8")]
)
# CUDA 로는 아예 모델을 올리지 않는다. 올렸다면 그 자리에서 죽는다.
self.assertEqual(factory.built, [("small", "cpu", "int8")])
def test_auto_keeps_cuda_when_warmup_succeeds(self) -> None:
def test_auto_keeps_cuda_when_the_probe_succeeds(self) -> None:
factory = _FakeTranscriberFactory()
transcriber = MODULE.build_transcriber(
"small", "auto", factory=factory, cuda_available=True
"small",
"auto",
factory=factory,
cuda_available=True,
prober=lambda model, device: True,
)
self.assertEqual(transcriber.device, "cuda")
self.assertEqual(len(factory.built), 1)
def test_explicit_cuda_never_downgrades_silently(self) -> None:
factory = _FakeTranscriberFactory(failing_device="cuda")
with self.assertRaises(MODULE.TranscriptionError):
MODULE.build_transcriber(
"small", "cuda", factory=factory, cuda_available=True
)
self.assertEqual(factory.built, [("small", "cuda", "float16")])
def test_cpu_warmup_failure_is_not_retried(self) -> None:
def test_explicit_cuda_never_downgrades_silently(self) -> None:
factory = _FakeTranscriberFactory()
with self.assertRaises(MODULE.TranscriptionError):
MODULE.build_transcriber(
"small",
"cuda",
factory=factory,
cuda_available=True,
prober=lambda model, device: False,
)
self.assertEqual(factory.built, [])
def test_cpu_path_is_not_probed(self) -> None:
factory = _FakeTranscriberFactory()
probes: list[tuple[str, str]] = []
MODULE.build_transcriber(
"small",
"cpu",
factory=factory,
cuda_available=True,
prober=lambda model, device: probes.append((model, device)) or True,
)
self.assertEqual(probes, [])
self.assertEqual(factory.built, [("small", "cpu", "int8")])
def test_python_level_warmup_failure_still_fails_closed(self) -> None:
factory = _FakeTranscriberFactory(failing_device="cpu")
with self.assertRaises(MODULE.TranscriptionError):
MODULE.build_transcriber(
"small", "auto", factory=factory, cuda_available=False
"small",
"auto",
factory=factory,
cuda_available=False,
prober=lambda model, device: True,
)
self.assertEqual(factory.built, [("small", "cpu", "int8")])
class CliTest(unittest.TestCase):