Compare commits
3 Commits
ce064d6894
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b2a53c54 | ||
|
|
89f8643426 | ||
|
|
9472387d1b |
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
@@ -32,10 +35,55 @@ app = FastAPI(title="ASR Faster-Whisper Worker")
|
|||||||
_MODEL_CACHE: Dict[str, WhisperModel] = {}
|
_MODEL_CACHE: Dict[str, WhisperModel] = {}
|
||||||
_DIARIZATION_PIPELINE: Any = None
|
_DIARIZATION_PIPELINE: Any = None
|
||||||
|
|
||||||
|
# Idle-unload / model-switch eviction: only one whisper model stays resident
|
||||||
|
# at a time (switching sizes frees the old one), and everything (including
|
||||||
|
# the diarization pipeline) is dropped after IDLE_UNLOAD_SECONDS of no
|
||||||
|
# requests, so this backend doesn't permanently hog GPU memory shared with
|
||||||
|
# the other ASR workers.
|
||||||
|
IDLE_UNLOAD_SECONDS = 120
|
||||||
|
_last_used: float = 0.0
|
||||||
|
_active_requests: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu() -> None:
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if DEVICE == "cuda" and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _unload_whisper_models() -> None:
|
||||||
|
if not _MODEL_CACHE:
|
||||||
|
return
|
||||||
|
print(f"[faster_whisper] unloading {list(_MODEL_CACHE.keys())}", flush=True)
|
||||||
|
_MODEL_CACHE.clear()
|
||||||
|
_free_gpu()
|
||||||
|
|
||||||
|
|
||||||
|
def _unload_diarization() -> None:
|
||||||
|
global _DIARIZATION_PIPELINE
|
||||||
|
if _DIARIZATION_PIPELINE is None:
|
||||||
|
return
|
||||||
|
print("[faster_whisper] unloading diarization pipeline", flush=True)
|
||||||
|
_DIARIZATION_PIPELINE = None
|
||||||
|
_free_gpu()
|
||||||
|
|
||||||
|
|
||||||
|
async def _idle_unload_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
if _active_requests == 0 and _last_used and (time.monotonic() - _last_used) >= IDLE_UNLOAD_SECONDS:
|
||||||
|
_unload_whisper_models()
|
||||||
|
_unload_diarization()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
async def startup() -> None:
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
|
asyncio.create_task(_idle_unload_loop())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -75,6 +123,8 @@ async def transcribe(
|
|||||||
tmp.write(await file.read())
|
tmp.write(await file.read())
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
global _active_requests, _last_used
|
||||||
|
_active_requests += 1
|
||||||
try:
|
try:
|
||||||
resolved_model = resolve_custom_model_path(custom_model_path) or model
|
resolved_model = resolve_custom_model_path(custom_model_path) or model
|
||||||
result = _transcribe(
|
result = _transcribe(
|
||||||
@@ -107,16 +157,33 @@ async def transcribe(
|
|||||||
raise HTTPException(status_code=500, detail=f"Worker failure: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Worker failure: {exc}") from exc
|
||||||
finally:
|
finally:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
_free_gpu()
|
||||||
|
_active_requests -= 1
|
||||||
|
_last_used = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
def _load_model(model_name: str) -> WhisperModel:
|
def _load_model(model_name: str) -> WhisperModel:
|
||||||
if model_name not in _MODEL_CACHE:
|
if model_name not in _MODEL_CACHE:
|
||||||
|
if _MODEL_CACHE:
|
||||||
|
# Only one whisper model resident at a time — switching sizes frees the old one.
|
||||||
|
_unload_whisper_models()
|
||||||
|
try:
|
||||||
_MODEL_CACHE[model_name] = WhisperModel(
|
_MODEL_CACHE[model_name] = WhisperModel(
|
||||||
model_name,
|
model_name,
|
||||||
device=DEVICE,
|
device=DEVICE,
|
||||||
compute_type=COMPUTE_TYPE,
|
compute_type=COMPUTE_TYPE,
|
||||||
download_root=str(MODEL_CACHE),
|
download_root=str(MODEL_CACHE),
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# A load interrupted partway (e.g. OOM) can leave CUDA memory
|
||||||
|
# fragmented/leaked in ways gc.collect()+empty_cache() don't
|
||||||
|
# reliably reclaim, and since _MODEL_CACHE never got populated
|
||||||
|
# the idle-unload loop has nothing to clean up either.
|
||||||
|
# Restarting the whole process is the only guaranteed way to get
|
||||||
|
# that memory back — supervisord's autorestart=true respawns it
|
||||||
|
# immediately.
|
||||||
|
print(f"[faster_whisper] model load failed, restarting process to reclaim GPU memory: {type(e).__name__}: {e}", flush=True)
|
||||||
|
os._exit(1)
|
||||||
return _MODEL_CACHE[model_name]
|
return _MODEL_CACHE[model_name]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import gc
|
import gc
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
@@ -29,6 +31,14 @@ app = FastAPI(title="ASR Qwen3 Worker")
|
|||||||
# and drive it through Qwen3ASRProcessor.apply_transcription_request().
|
# and drive it through Qwen3ASRProcessor.apply_transcription_request().
|
||||||
_MODEL_CACHE: Dict[str, Tuple[Any, Any]] = {}
|
_MODEL_CACHE: Dict[str, Tuple[Any, Any]] = {}
|
||||||
|
|
||||||
|
# Idle-unload / model-switch eviction: only one model stays resident at a
|
||||||
|
# time, and the whole cache is dropped after IDLE_UNLOAD_SECONDS of no
|
||||||
|
# requests, so this backend doesn't permanently hog GPU memory shared with
|
||||||
|
# the other ASR workers.
|
||||||
|
IDLE_UNLOAD_SECONDS = 120
|
||||||
|
_last_used: float = 0.0
|
||||||
|
_active_requests: int = 0
|
||||||
|
|
||||||
LANG_MAP = {
|
LANG_MAP = {
|
||||||
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
|
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
|
||||||
"fr": "french", "de": "german", "es": "spanish", "ru": "russian",
|
"fr": "french", "de": "german", "es": "spanish", "ru": "russian",
|
||||||
@@ -70,16 +80,36 @@ def _to_wav(src_path: Path) -> Path:
|
|||||||
return wav_path
|
return wav_path
|
||||||
|
|
||||||
|
|
||||||
|
def _unload_models() -> None:
|
||||||
|
if not _MODEL_CACHE:
|
||||||
|
return
|
||||||
|
_log(f"unloading {list(_MODEL_CACHE.keys())}")
|
||||||
|
_MODEL_CACHE.clear()
|
||||||
|
_free_gpu()
|
||||||
|
|
||||||
|
|
||||||
|
async def _idle_unload_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
if _active_requests == 0 and _last_used and (time.monotonic() - _last_used) >= IDLE_UNLOAD_SECONDS:
|
||||||
|
_unload_models()
|
||||||
|
|
||||||
|
|
||||||
def _load_model(model_id: str) -> Tuple[Any, Any]:
|
def _load_model(model_id: str) -> Tuple[Any, Any]:
|
||||||
if model_id in _MODEL_CACHE:
|
if model_id in _MODEL_CACHE:
|
||||||
return _MODEL_CACHE[model_id]
|
return _MODEL_CACHE[model_id]
|
||||||
|
|
||||||
|
if _MODEL_CACHE:
|
||||||
|
# Only one model resident at a time — switching models frees the old one.
|
||||||
|
_unload_models()
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
|
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
|
||||||
|
|
||||||
_log(f"loading model {model_id}")
|
_log(f"loading model {model_id}")
|
||||||
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
|
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
|
||||||
|
|
||||||
|
try:
|
||||||
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
|
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
|
||||||
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
model = AutoModelForSpeechSeq2Seq.from_pretrained(
|
||||||
model_id,
|
model_id,
|
||||||
@@ -89,6 +119,15 @@ def _load_model(model_id: str) -> Tuple[Any, Any]:
|
|||||||
)
|
)
|
||||||
model.to(DEVICE)
|
model.to(DEVICE)
|
||||||
model.eval()
|
model.eval()
|
||||||
|
except Exception as e:
|
||||||
|
# A load interrupted partway (e.g. OOM) can leave CUDA memory
|
||||||
|
# fragmented/leaked in ways gc.collect()+empty_cache() don't reliably
|
||||||
|
# reclaim, and since _MODEL_CACHE never got populated the idle-unload
|
||||||
|
# loop has nothing to clean up either. Restarting the whole process
|
||||||
|
# is the only guaranteed way to get that memory back — supervisord's
|
||||||
|
# autorestart=true respawns it immediately.
|
||||||
|
_log(f"model load failed, restarting process to reclaim GPU memory: {type(e).__name__}: {e}")
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
_MODEL_CACHE[model_id] = (model, processor)
|
_MODEL_CACHE[model_id] = (model, processor)
|
||||||
_log(f"model {model_id} loaded")
|
_log(f"model {model_id} loaded")
|
||||||
@@ -96,8 +135,9 @@ def _load_model(model_id: str) -> Tuple[Any, Any]:
|
|||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
async def startup() -> None:
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
|
asyncio.create_task(_idle_unload_loop())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -118,6 +158,8 @@ async def transcribe(
|
|||||||
tmp.write(await file.read())
|
tmp.write(await file.read())
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
global _active_requests, _last_used
|
||||||
|
_active_requests += 1
|
||||||
wav_path: Optional[Path] = None
|
wav_path: Optional[Path] = None
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
@@ -162,6 +204,21 @@ async def transcribe(
|
|||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
if wav_path is not None:
|
if wav_path is not None:
|
||||||
wav_path.unlink(missing_ok=True)
|
wav_path.unlink(missing_ok=True)
|
||||||
|
# Long files blow up KV-cache/activation memory; without this, that
|
||||||
|
# memory stays reserved by this process and starves the other backends
|
||||||
|
# sharing the same GPU until the container restarts. (del locals()[...]
|
||||||
|
# does NOT work in CPython, hence the explicit names.)
|
||||||
|
try:
|
||||||
|
del inputs
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
del generated
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
_free_gpu()
|
||||||
|
_active_requests -= 1
|
||||||
|
_last_used = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import gc
|
import gc
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -24,20 +26,58 @@ app = FastAPI(title="ASR VibeVoice Worker")
|
|||||||
|
|
||||||
_MODEL_CACHE: Dict[str, Any] = {}
|
_MODEL_CACHE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Idle-unload / model-switch eviction: only one model stays resident at a
|
||||||
|
# time, and the whole cache is dropped after IDLE_UNLOAD_SECONDS of no
|
||||||
|
# requests, so this backend doesn't permanently hog GPU memory shared with
|
||||||
|
# the other ASR workers.
|
||||||
|
IDLE_UNLOAD_SECONDS = 120
|
||||||
|
_last_used: float = 0.0
|
||||||
|
_active_requests: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str) -> None:
|
def _log(msg: str) -> None:
|
||||||
print(f"[vibevoice] {msg}", flush=True)
|
print(f"[vibevoice] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu() -> None:
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if DEVICE == "cuda" and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _unload_models() -> None:
|
||||||
|
if not _MODEL_CACHE:
|
||||||
|
return
|
||||||
|
_log(f"unloading {list(_MODEL_CACHE.keys())}")
|
||||||
|
_MODEL_CACHE.clear()
|
||||||
|
_free_gpu()
|
||||||
|
|
||||||
|
|
||||||
|
async def _idle_unload_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
if _active_requests == 0 and _last_used and (time.monotonic() - _last_used) >= IDLE_UNLOAD_SECONDS:
|
||||||
|
_unload_models()
|
||||||
|
|
||||||
|
|
||||||
def _load_model(model_id: str) -> Any:
|
def _load_model(model_id: str) -> Any:
|
||||||
if model_id in _MODEL_CACHE:
|
if model_id in _MODEL_CACHE:
|
||||||
return _MODEL_CACHE[model_id]
|
return _MODEL_CACHE[model_id]
|
||||||
|
|
||||||
|
if _MODEL_CACHE:
|
||||||
|
# Only one model resident at a time — switching models frees the old one.
|
||||||
|
_unload_models()
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration
|
from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration
|
||||||
from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
|
from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
|
||||||
|
|
||||||
_log(f"loading model {model_id}")
|
_log(f"loading model {model_id}")
|
||||||
|
try:
|
||||||
processor = VibeVoiceASRProcessor.from_pretrained(
|
processor = VibeVoiceASRProcessor.from_pretrained(
|
||||||
model_id,
|
model_id,
|
||||||
language_model_pretrained_name="Qwen/Qwen2.5-1.5B",
|
language_model_pretrained_name="Qwen/Qwen2.5-1.5B",
|
||||||
@@ -52,6 +92,15 @@ def _load_model(model_id: str) -> Any:
|
|||||||
)
|
)
|
||||||
model.to(DEVICE)
|
model.to(DEVICE)
|
||||||
model.eval()
|
model.eval()
|
||||||
|
except Exception as e:
|
||||||
|
# A load interrupted partway (e.g. OOM) can leave CUDA memory
|
||||||
|
# fragmented/leaked in ways gc.collect()+empty_cache() don't reliably
|
||||||
|
# reclaim, and since _MODEL_CACHE never got populated the idle-unload
|
||||||
|
# loop has nothing to clean up either. Restarting the whole process
|
||||||
|
# is the only guaranteed way to get that memory back — supervisord's
|
||||||
|
# autorestart=true respawns it immediately.
|
||||||
|
_log(f"model load failed, restarting process to reclaim GPU memory: {type(e).__name__}: {e}")
|
||||||
|
os._exit(1)
|
||||||
|
|
||||||
_MODEL_CACHE[model_id] = (model, processor)
|
_MODEL_CACHE[model_id] = (model, processor)
|
||||||
_log(f"model {model_id} loaded")
|
_log(f"model {model_id} loaded")
|
||||||
@@ -59,8 +108,9 @@ def _load_model(model_id: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
async def startup() -> None:
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
|
asyncio.create_task(_idle_unload_loop())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -81,6 +131,8 @@ async def transcribe(
|
|||||||
tmp.write(await file.read())
|
tmp.write(await file.read())
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
global _active_requests, _last_used
|
||||||
|
_active_requests += 1
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
@@ -153,6 +205,25 @@ async def transcribe(
|
|||||||
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
||||||
finally:
|
finally:
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
# Long files blow up KV-cache/activation memory; without this, that
|
||||||
|
# memory stays reserved by this process and starves the other backends
|
||||||
|
# sharing the same GPU until the container restarts. (del locals()[...]
|
||||||
|
# does NOT work in CPython, hence the explicit names.)
|
||||||
|
try:
|
||||||
|
del inputs
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
del output_ids
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
del generated_ids
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
_free_gpu()
|
||||||
|
_active_requests -= 1
|
||||||
|
_last_used = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
@@ -28,6 +31,13 @@ app = FastAPI(title="TTS XTTS Worker")
|
|||||||
MODEL_NAME = "tts_models/multilingual/multi-dataset/xtts_v2"
|
MODEL_NAME = "tts_models/multilingual/multi-dataset/xtts_v2"
|
||||||
_MODEL_CACHE: Dict[str, Any] = {}
|
_MODEL_CACHE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Idle-unload: the model is dropped after IDLE_UNLOAD_SECONDS of no synthesis
|
||||||
|
# requests, so this backend doesn't permanently hog GPU memory shared with
|
||||||
|
# the ASR workers.
|
||||||
|
IDLE_UNLOAD_SECONDS = 120
|
||||||
|
_last_used: float = 0.0
|
||||||
|
_active_requests: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str) -> None:
|
def _log(msg: str) -> None:
|
||||||
print(f"[xtts] {msg}", flush=True)
|
print(f"[xtts] {msg}", flush=True)
|
||||||
@@ -41,6 +51,31 @@ def _device() -> str:
|
|||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu() -> None:
|
||||||
|
gc.collect()
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _unload_model() -> None:
|
||||||
|
if not _MODEL_CACHE:
|
||||||
|
return
|
||||||
|
_log("unloading model (idle)")
|
||||||
|
_MODEL_CACHE.clear()
|
||||||
|
_free_gpu()
|
||||||
|
|
||||||
|
|
||||||
|
async def _idle_unload_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
if _active_requests == 0 and _last_used and (time.monotonic() - _last_used) >= IDLE_UNLOAD_SECONDS:
|
||||||
|
_unload_model()
|
||||||
|
|
||||||
|
|
||||||
def _load_model() -> Any:
|
def _load_model() -> Any:
|
||||||
if MODEL_NAME in _MODEL_CACHE:
|
if MODEL_NAME in _MODEL_CACHE:
|
||||||
return _MODEL_CACHE[MODEL_NAME]
|
return _MODEL_CACHE[MODEL_NAME]
|
||||||
@@ -48,15 +83,26 @@ def _load_model() -> Any:
|
|||||||
from TTS.api import TTS
|
from TTS.api import TTS
|
||||||
|
|
||||||
_log(f"loading model {MODEL_NAME}")
|
_log(f"loading model {MODEL_NAME}")
|
||||||
|
try:
|
||||||
tts = TTS(MODEL_NAME).to(_device())
|
tts = TTS(MODEL_NAME).to(_device())
|
||||||
|
except Exception as e:
|
||||||
|
# A load interrupted partway (e.g. OOM) can leave CUDA memory
|
||||||
|
# fragmented/leaked in ways gc.collect()+empty_cache() don't reliably
|
||||||
|
# reclaim, and since _MODEL_CACHE never got populated the idle-unload
|
||||||
|
# loop has nothing to clean up either. Restarting the whole process
|
||||||
|
# is the only guaranteed way to get that memory back — supervisord's
|
||||||
|
# autorestart=true respawns it immediately.
|
||||||
|
_log(f"model load failed, restarting process to reclaim GPU memory: {type(e).__name__}: {e}")
|
||||||
|
os._exit(1)
|
||||||
_MODEL_CACHE[MODEL_NAME] = tts
|
_MODEL_CACHE[MODEL_NAME] = tts
|
||||||
_log("model loaded")
|
_log("model loaded")
|
||||||
return tts
|
return tts
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
async def startup() -> None:
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
|
asyncio.create_task(_idle_unload_loop())
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -74,10 +120,14 @@ def synthesize(
|
|||||||
if not speaker_path.exists():
|
if not speaker_path.exists():
|
||||||
raise HTTPException(status_code=400, detail=f"speaker_wav not found: {speaker_wav}")
|
raise HTTPException(status_code=400, detail=f"speaker_wav not found: {speaker_wav}")
|
||||||
|
|
||||||
|
global _active_requests, _last_used
|
||||||
|
_active_requests += 1
|
||||||
try:
|
try:
|
||||||
tts = _load_model()
|
tts = _load_model()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"model load error: {type(e).__name__}: {e}")
|
_log(f"model load error: {type(e).__name__}: {e}")
|
||||||
|
_active_requests -= 1
|
||||||
|
_last_used = time.monotonic()
|
||||||
raise HTTPException(status_code=500, detail=f"모델 로드 실패: {type(e).__name__}: {e}")
|
raise HTTPException(status_code=500, detail=f"모델 로드 실패: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||||
@@ -105,6 +155,9 @@ def synthesize(
|
|||||||
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
||||||
finally:
|
finally:
|
||||||
out_path.unlink(missing_ok=True)
|
out_path.unlink(missing_ok=True)
|
||||||
|
_free_gpu()
|
||||||
|
_active_requests -= 1
|
||||||
|
_last_used = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ async function loadConfig() {
|
|||||||
const r = await fetch('/asr/config');
|
const r = await fetch('/asr/config');
|
||||||
SERVER_CONFIG = await r.json();
|
SERVER_CONFIG = await r.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
SERVER_CONFIG = {
|
// No hardcoded fallback list here on purpose: the server's /asr/config is
|
||||||
default_backend: 'faster-whisper',
|
// the single source of truth for backend/model names. A duplicated guess
|
||||||
default_model: 'large-v3',
|
// here would silently drift out of sync with it (this has already bitten
|
||||||
default_language: 'ko',
|
// us once — see qwen3 model IDs). Surface the failure instead.
|
||||||
backends: {
|
SERVER_CONFIG = null;
|
||||||
'faster-whisper': { models: ['tiny','base','small','medium','large-v3','large-v2','turbo'] },
|
const el = document.getElementById('file-status');
|
||||||
'qwen3': { models: ['Qwen/Qwen3-ASR-0.6B-hf','Qwen/Qwen3-ASR-1.7B-hf'] },
|
if (el) el.textContent = '설정을 불러오지 못했습니다. 페이지를 새로고침해주세요.';
|
||||||
'vibevoice': { models: ['microsoft/VibeVoice-ASR'] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
initAsrUI();
|
initAsrUI();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
faster-whisper==1.1.1
|
faster-whisper==1.1.1
|
||||||
pyannote.audio>=3.1
|
pyannote.audio==4.0.7
|
||||||
numpy
|
numpy
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
transformers>=4.45.0
|
transformers==5.14.1
|
||||||
accelerate>=0.30.0
|
accelerate>=0.30.0
|
||||||
librosa>=0.10.0
|
librosa>=0.10.0
|
||||||
soundfile>=0.12.0
|
soundfile>=0.12.0
|
||||||
|
|||||||
50
scripts/smoke_test.sh
Executable file
50
scripts/smoke_test.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Minimal post-deploy smoke test. Hits every ASR/TTS worker's own endpoint
|
||||||
|
# directly (bypassing the gateway's OIDC auth) with a synthetic test clip, to
|
||||||
|
# catch startup/dependency/API-compat regressions before a user has to find
|
||||||
|
# them (qwen3's wrong model IDs, pyannote's use_auth_token/token rename, and
|
||||||
|
# the cuDNN LD_LIBRARY_PATH conflict all would have shown up here as an
|
||||||
|
# immediate FAIL instead of a silent break discovered later).
|
||||||
|
#
|
||||||
|
# Run this after every `bash build.sh` + redeploy.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
CONTAINER="${SPEECH_CONTAINER:-speech}"
|
||||||
|
TMP_WAV="/tmp/smoke_test_tone.wav"
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { printf ' OK %s\n' "$1"; }
|
||||||
|
fail() { printf 'FAIL %s: %s\n' "$1" "$2"; FAIL=1; }
|
||||||
|
|
||||||
|
echo "=== generating synthetic test clip ==="
|
||||||
|
podman exec "$CONTAINER" ffmpeg -y -f lavfi -i "sine=frequency=440:duration=3" -ar 16000 -ac 1 "$TMP_WAV" >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "=== faster-whisper (8001) ==="
|
||||||
|
resp=$(podman exec "$CONTAINER" curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8001/transcribe \
|
||||||
|
-F "file=@${TMP_WAV};type=audio/wav" -F "model=tiny" -F "language=ko")
|
||||||
|
[ "$resp" = "200" ] && pass "faster-whisper" || fail "faster-whisper" "HTTP $resp"
|
||||||
|
|
||||||
|
echo "=== qwen3 (8004) ==="
|
||||||
|
resp=$(podman exec "$CONTAINER" curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8004/transcribe \
|
||||||
|
-F "file=@${TMP_WAV};type=audio/wav" -F "model=Qwen/Qwen3-ASR-0.6B-hf" -F "language=ko")
|
||||||
|
[ "$resp" = "200" ] && pass "qwen3" || fail "qwen3" "HTTP $resp"
|
||||||
|
|
||||||
|
echo "=== vibevoice (8006) ==="
|
||||||
|
resp=$(podman exec "$CONTAINER" curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8006/transcribe \
|
||||||
|
-F "file=@${TMP_WAV};type=audio/wav" -F "model=microsoft/VibeVoice-ASR")
|
||||||
|
[ "$resp" = "200" ] && pass "vibevoice" || fail "vibevoice" "HTTP $resp"
|
||||||
|
|
||||||
|
echo "=== xtts (8005) ==="
|
||||||
|
resp=$(podman exec "$CONTAINER" curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8005/synthesize \
|
||||||
|
-F "text=스모크 테스트입니다." -F "language=ko" -F "speaker_wav=${TMP_WAV}")
|
||||||
|
[ "$resp" = "200" ] && pass "xtts" || fail "xtts" "HTTP $resp"
|
||||||
|
|
||||||
|
podman exec "$CONTAINER" rm -f "$TMP_WAV"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" = "0" ]; then
|
||||||
|
echo "all backends OK"
|
||||||
|
else
|
||||||
|
echo "one or more backends FAILED — check: podman logs $CONTAINER"
|
||||||
|
fi
|
||||||
|
exit "$FAIL"
|
||||||
Reference in New Issue
Block a user