Restructure into asr/tts packages and add TTS tab skeleton

Split the single ASR gateway into app/main.py (entrypoint) + app/core
(shared env helpers) + app/asr (all existing ASR logic, unchanged
behavior) + app/tts (placeholder router/config, no engine yet), so a
real TTS backend can be dropped in later without reshuffling ASR code.
API moved under /asr/* and /tts/* prefixes; UI gained a top-level
ASR/TTS tab switcher built on a reusable nested .tab-group mechanism,
with JS split into common.js/asr.js/tts.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
du5t
2026-06-18 11:28:43 +09:00
parent ab6df14044
commit cabbdac39b
18 changed files with 592 additions and 463 deletions

0
app/asr/__init__.py Normal file
View File

40
app/asr/config.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
from core.config import env_str
ASR_BASE_DIR = Path(env_str("ASR_BASE_DIR", "/srv/asr"))
MODEL_CACHE = Path(env_str("WHISPER_CACHE_DIR", str(ASR_BASE_DIR / "models-cache")))
UPLOAD_DIR = ASR_BASE_DIR / "uploads"
RESULT_DIR = ASR_BASE_DIR / "results"
CUSTOM_MODEL_DIR = ASR_BASE_DIR / "custom-models"
DEVICE = env_str("ASR_DEVICE", "cuda")
COMPUTE_TYPE = env_str("ASR_COMPUTE_TYPE", "float16")
DEFAULT_BACKEND = env_str("DEFAULT_BACKEND", "faster-whisper")
DEFAULT_MODEL = env_str("DEFAULT_MODEL", "large-v3")
DEFAULT_LANGUAGE = env_str("DEFAULT_LANGUAGE", "ko")
FASTER_WHISPER_URL = env_str("FASTER_WHISPER_URL", "http://127.0.0.1:8001")
QWEN3_URL = env_str("QWEN3_URL", "http://127.0.0.1:8004")
PYANNOTE_HF_TOKEN = env_str("PYANNOTE_HF_TOKEN", "")
def ensure_runtime_dirs() -> None:
for p in [ASR_BASE_DIR, MODEL_CACHE, UPLOAD_DIR, RESULT_DIR, CUSTOM_MODEL_DIR]:
p.mkdir(parents=True, exist_ok=True)
def resolve_custom_model_path(custom_model_path: Optional[str]) -> Optional[str]:
if not custom_model_path:
return None
raw = custom_model_path.strip()
if not raw:
return None
candidate = Path(raw)
if candidate.is_absolute():
return str(candidate)
return str((CUSTOM_MODEL_DIR / candidate).resolve())

354
app/asr/router.py Normal file
View File

@@ -0,0 +1,354 @@
from __future__ import annotations
import io
import json
import struct
import wave
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from asr.config import (
DEFAULT_BACKEND,
DEFAULT_LANGUAGE,
DEFAULT_MODEL,
FASTER_WHISPER_URL,
QWEN3_URL,
RESULT_DIR,
UPLOAD_DIR,
ensure_runtime_dirs,
)
router = APIRouter()
BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL,
"qwen3": QWEN3_URL,
}
REALTIME_SAMPLE_RATE = 16000
REALTIME_CHUNK_SECONDS = 3
# ─── Config ────────────────────────────────────────────────────────────────────
@router.get("/config")
def config() -> Dict[str, Any]:
return {
"default_backend": DEFAULT_BACKEND,
"default_model": DEFAULT_MODEL,
"default_language": DEFAULT_LANGUAGE,
"backends": {
"faster-whisper": {
"models": ["tiny", "base", "small", "medium", "large-v3", "large-v2", "turbo"],
},
"qwen3": {
"models": ["Qwen/Qwen3-ASR-2B", "Qwen/Qwen3-ASR-8B"],
},
},
}
# ─── History ──────────────────────────────────────────────────────────────────
@router.get("/history")
def list_history() -> List[Dict[str, Any]]:
ensure_runtime_dirs()
records = []
for p in sorted(RESULT_DIR.glob("*.json"), reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
upload_path = data.get("upload_file", "")
upload_name = Path(upload_path).name if upload_path else ""
records.append({
"id": p.stem,
"filename": data.get("_filename", upload_name),
"backend": data.get("gateway_backend", data.get("backend", "")),
"model": data.get("model", ""),
"language": data.get("language", ""),
"duration": data.get("duration"),
"text_preview": (data.get("text", "") or "")[:120],
"upload_file": upload_name,
"diarized": data.get("diarized", False),
})
except Exception:
continue
return records
@router.get("/history/{record_id}")
def get_history(record_id: str) -> Dict[str, Any]:
safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json"
if not p.exists():
raise HTTPException(status_code=404, detail="Record not found")
return json.loads(p.read_text(encoding="utf-8"))
@router.delete("/history/{record_id}")
def delete_history(record_id: str) -> Dict[str, str]:
safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json"
if not p.exists():
raise HTTPException(status_code=404, detail="Record not found")
# Also remove upload file if referenced
try:
data = json.loads(p.read_text(encoding="utf-8"))
upload_path = data.get("upload_file", "")
if upload_path:
up = Path(upload_path)
if up.exists() and up.is_relative_to(UPLOAD_DIR):
up.unlink(missing_ok=True)
except Exception:
pass
p.unlink(missing_ok=True)
return {"status": "deleted", "id": safe_id}
@router.get("/uploads/{filename}")
def serve_upload(filename: str) -> FileResponse:
safe_name = Path(filename).name
p = UPLOAD_DIR / safe_name
if not p.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(p)
# ─── File Transcription ───────────────────────────────────────────────────────
@router.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
backend: str = Form(DEFAULT_BACKEND),
model: str = Form(DEFAULT_MODEL),
custom_model_path: Optional[str] = Form(None),
language: Optional[str] = Form(None),
task: str = Form("transcribe"),
# faster-whisper options
beam_size: int = Form(5),
temperature: float = Form(0.0),
word_timestamps: bool = Form(False),
diarize: bool = Form(False),
num_speakers: Optional[int] = Form(None),
min_speakers: Optional[int] = Form(None),
max_speakers: Optional[int] = Form(None),
no_repeat_ngram_size: int = Form(0),
repetition_penalty: float = Form(1.0),
compression_ratio_threshold: float = Form(2.4),
log_prob_threshold: float = Form(-1.0),
no_speech_threshold: float = Form(0.6),
condition_on_previous_text: bool = Form(True),
) -> Dict[str, Any]:
if backend not in BACKENDS:
raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")
ensure_runtime_dirs()
original_filename = file.filename or "upload.bin"
suffix = Path(original_filename).suffix or ".bin"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
saved_upload = UPLOAD_DIR / f"{timestamp}{suffix}"
content = await file.read()
saved_upload.write_bytes(content)
try:
worker_url = BACKENDS[backend]
effective_language = language or DEFAULT_LANGUAGE
if backend == "qwen3":
data: Dict[str, str] = {
"model": model,
"language": effective_language,
"task": task,
}
else:
# faster-whisper
data = {
"model": model,
"custom_model_path": custom_model_path or "",
"language": effective_language,
"task": task,
"beam_size": str(beam_size),
"temperature": str(temperature),
"word_timestamps": str(word_timestamps).lower(),
"diarize": str(diarize).lower(),
"no_repeat_ngram_size": str(no_repeat_ngram_size),
"repetition_penalty": str(repetition_penalty),
"compression_ratio_threshold": str(compression_ratio_threshold),
"log_prob_threshold": str(log_prob_threshold),
"no_speech_threshold": str(no_speech_threshold),
"condition_on_previous_text": str(condition_on_previous_text).lower(),
}
if num_speakers is not None:
data["num_speakers"] = str(num_speakers)
if min_speakers is not None:
data["min_speakers"] = str(min_speakers)
if max_speakers is not None:
data["max_speakers"] = str(max_speakers)
files_payload = {
"file": (original_filename, content, file.content_type or "application/octet-stream")
}
async with httpx.AsyncClient(timeout=httpx.Timeout(3600.0, connect=60.0)) as client:
response = await client.post(f"{worker_url}/transcribe", data=data, files=files_payload)
if response.status_code >= 400:
raise HTTPException(status_code=response.status_code, detail=f"Backend error: {response.text}")
payload = response.json()
payload["gateway_backend"] = backend
payload["_filename"] = original_filename
payload["_timestamp"] = timestamp
payload["upload_file"] = str(saved_upload)
result_path = RESULT_DIR / f"{timestamp}.json"
result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
payload["result_file"] = str(result_path)
return payload
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Gateway error: {exc}") from exc
# ─── Real-time WebSocket (faster-whisper only) ────────────────────────────────
def _pcm_float32_to_wav(pcm_bytes: bytes, sample_rate: int = 16000) -> bytes:
n = len(pcm_bytes) // 4
try:
import numpy as np
arr = np.frombuffer(pcm_bytes, dtype=np.float32)
arr_i16 = np.clip(arr * 32767.0, -32768, 32767).astype(np.int16)
raw16 = arr_i16.tobytes()
except ImportError:
raw16 = b"".join(
struct.pack("<h", max(-32768, min(32767, int(struct.unpack("<f", pcm_bytes[i*4:(i+1)*4])[0] * 32767))))
for i in range(n)
)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(raw16)
return buf.getvalue()
async def _send_chunk(wav_bytes: bytes, *, model: str, language: str, beam_size: int,
no_repeat_ngram_size: int, repetition_penalty: float,
compression_ratio_threshold: float, log_prob_threshold: float,
no_speech_threshold: float, condition_on_previous_text: bool) -> Dict[str, Any]:
data = {
"model": model,
"language": language,
"task": "transcribe",
"beam_size": str(beam_size),
"temperature": "0",
"word_timestamps": "false",
"diarize": "false",
"no_repeat_ngram_size": str(no_repeat_ngram_size),
"repetition_penalty": str(repetition_penalty),
"compression_ratio_threshold": str(compression_ratio_threshold),
"log_prob_threshold": str(log_prob_threshold),
"no_speech_threshold": str(no_speech_threshold),
"condition_on_previous_text": str(condition_on_previous_text).lower(),
}
files_payload = {"file": ("chunk.wav", wav_bytes, "audio/wav")}
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=30.0)) as client:
resp = await client.post(f"{FASTER_WHISPER_URL}/transcribe", data=data, files=files_payload)
resp.raise_for_status()
return resp.json()
@router.websocket("/ws/realtime")
async def realtime_ws(websocket: WebSocket) -> None:
await websocket.accept()
audio_buf = bytearray()
partial_texts: List[str] = []
all_segments: List[Dict[str, Any]] = []
time_offset = 0.0
try:
cfg = json.loads(await websocket.receive_text())
model = cfg.get("model", DEFAULT_MODEL)
language = cfg.get("language") or DEFAULT_LANGUAGE
beam_size = int(cfg.get("beam_size", 5))
no_repeat_ngram_size = int(cfg.get("no_repeat_ngram_size", 0))
repetition_penalty = float(cfg.get("repetition_penalty", 1.0))
compression_ratio_threshold = float(cfg.get("compression_ratio_threshold", 2.4))
log_prob_threshold = float(cfg.get("log_prob_threshold", -1.0))
no_speech_threshold = float(cfg.get("no_speech_threshold", 0.6))
condition_on_previous_text = bool(cfg.get("condition_on_previous_text", True))
chunk_seconds = int(cfg.get("chunk_seconds", REALTIME_CHUNK_SECONDS))
chunk_bytes = chunk_seconds * REALTIME_SAMPLE_RATE * 4
await websocket.send_json({"type": "ready"})
async def process_buffer(buf: bytearray) -> None:
nonlocal time_offset
if len(buf) < REALTIME_SAMPLE_RATE * 4 * 0.3:
return
wav = _pcm_float32_to_wav(bytes(buf))
result = await _send_chunk(
wav, model=model, language=language, beam_size=beam_size,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
text = result.get("text", "").strip()
segs = result.get("segments", [])
for seg in segs:
seg["start"] = round(seg.get("start", 0) + time_offset, 3)
seg["end"] = round(seg.get("end", 0) + time_offset, 3)
time_offset += len(buf) / 4 / REALTIME_SAMPLE_RATE
if text:
partial_texts.append(text)
all_segments.extend(segs)
await websocket.send_json({
"type": "partial",
"text": text,
"segments": segs,
"full_text": " ".join(x for x in partial_texts if x),
})
while True:
data = await websocket.receive()
if data.get("type") == "websocket.disconnect":
break
if "bytes" in data:
audio_buf.extend(data["bytes"])
while len(audio_buf) >= chunk_bytes:
await process_buffer(bytearray(audio_buf[:chunk_bytes]))
audio_buf = audio_buf[chunk_bytes:]
elif "text" in data:
msg = json.loads(data["text"])
if msg.get("cmd") == "stop":
if audio_buf:
await process_buffer(audio_buf)
await websocket.send_json({
"type": "final",
"text": " ".join(x for x in partial_texts if x),
"segments": all_segments,
"diarized": False,
})
break
except WebSocketDisconnect:
pass
except Exception as e:
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
pass

View File

@@ -0,0 +1,273 @@
from __future__ import annotations
import argparse
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from faster_whisper import WhisperModel
import sys
sys.path.insert(0, "/app")
from asr.config import (
COMPUTE_TYPE,
CUSTOM_MODEL_DIR,
DEFAULT_MODEL,
DEVICE,
MODEL_CACHE,
PYANNOTE_HF_TOKEN,
ensure_runtime_dirs,
resolve_custom_model_path,
)
app = FastAPI(title="ASR Faster-Whisper Worker")
_MODEL_CACHE: Dict[str, WhisperModel] = {}
_DIARIZATION_PIPELINE: Any = None
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
@app.get("/health")
def health() -> Dict[str, Any]:
return {
"status": "ok",
"device": DEVICE,
"compute_type": COMPUTE_TYPE,
"diarization_available": bool(PYANNOTE_HF_TOKEN),
}
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
model: str = Form(DEFAULT_MODEL),
custom_model_path: Optional[str] = Form(None),
language: Optional[str] = Form(None),
task: str = Form("transcribe"),
beam_size: int = Form(5),
temperature: float = Form(0.0),
word_timestamps: bool = Form(False),
diarize: bool = Form(False),
num_speakers: Optional[int] = Form(None),
min_speakers: Optional[int] = Form(None),
max_speakers: Optional[int] = Form(None),
no_repeat_ngram_size: int = Form(0),
repetition_penalty: float = Form(1.0),
compression_ratio_threshold: float = Form(2.4),
log_prob_threshold: float = Form(-1.0),
no_speech_threshold: float = Form(0.6),
condition_on_previous_text: bool = Form(True),
) -> Dict[str, Any]:
ensure_runtime_dirs()
suffix = Path(file.filename or "upload.bin").suffix or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = Path(tmp.name)
try:
resolved_model = resolve_custom_model_path(custom_model_path) or model
result = _transcribe(
audio_path=tmp_path,
model_name=resolved_model,
language=language or None,
task=task,
beam_size=beam_size,
temperature=temperature,
word_timestamps=word_timestamps,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
if diarize:
result = _apply_diarization(
audio_path=tmp_path,
result=result,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
)
return result
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Worker failure: {exc}") from exc
finally:
tmp_path.unlink(missing_ok=True)
def _load_model(model_name: str) -> WhisperModel:
if model_name not in _MODEL_CACHE:
_MODEL_CACHE[model_name] = WhisperModel(
model_name,
device=DEVICE,
compute_type=COMPUTE_TYPE,
download_root=str(MODEL_CACHE),
)
return _MODEL_CACHE[model_name]
def _transcribe(
audio_path: Path,
model_name: str,
language: Optional[str],
task: str,
beam_size: int,
temperature: float,
word_timestamps: bool,
no_repeat_ngram_size: int,
repetition_penalty: float,
compression_ratio_threshold: float,
log_prob_threshold: float,
no_speech_threshold: float,
condition_on_previous_text: bool,
) -> Dict[str, Any]:
model = _load_model(model_name)
temperatures: Union[float, List[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] if temperature == 0.0 else temperature
segments_iter, info = model.transcribe(
str(audio_path),
language=language,
task=task,
beam_size=beam_size,
temperature=temperatures,
word_timestamps=word_timestamps,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
segment_list = []
full_text = []
for seg in segments_iter:
seg_dict: Dict[str, Any] = {
"id": seg.id,
"start": round(float(seg.start), 3),
"end": round(float(seg.end), 3),
"text": seg.text,
"avg_logprob": round(float(seg.avg_logprob), 4) if seg.avg_logprob is not None else None,
"compression_ratio": round(float(seg.compression_ratio), 4) if seg.compression_ratio is not None else None,
"no_speech_prob": round(float(seg.no_speech_prob), 4) if seg.no_speech_prob is not None else None,
}
if word_timestamps and getattr(seg, "words", None):
seg_dict["words"] = [
{
"start": round(float(w.start), 3),
"end": round(float(w.end), 3),
"word": w.word,
"probability": round(float(w.probability), 4),
}
for w in seg.words
]
segment_list.append(seg_dict)
full_text.append(seg.text.strip())
duration = None
try:
duration = round(float(info.duration), 3)
except Exception:
pass
return {
"backend": "faster-whisper",
"model": model_name,
"language": getattr(info, "language", language),
"language_probability": round(float(getattr(info, "language_probability", 0) or 0), 4),
"duration": duration,
"text": " ".join(x for x in full_text if x),
"segments": segment_list,
"diarized": False,
}
def _get_diarization_pipeline():
global _DIARIZATION_PIPELINE
if _DIARIZATION_PIPELINE is None:
try:
from pyannote.audio import Pipeline
import torch
except ImportError as exc:
raise RuntimeError("pyannote.audio가 설치되지 않았습니다.") from exc
if not PYANNOTE_HF_TOKEN:
raise RuntimeError("화자 분리를 사용하려면 PYANNOTE_HF_TOKEN 환경변수를 설정하세요.")
_DIARIZATION_PIPELINE = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token=PYANNOTE_HF_TOKEN,
)
if DEVICE == "cuda":
import torch
_DIARIZATION_PIPELINE = _DIARIZATION_PIPELINE.to(torch.device("cuda"))
return _DIARIZATION_PIPELINE
def _apply_diarization(
audio_path: Path,
result: Dict[str, Any],
num_speakers: Optional[int],
min_speakers: Optional[int],
max_speakers: Optional[int],
) -> Dict[str, Any]:
pipeline = _get_diarization_pipeline()
kwargs: Dict[str, Any] = {}
if num_speakers is not None:
kwargs["num_speakers"] = num_speakers
else:
if min_speakers is not None:
kwargs["min_speakers"] = min_speakers
if max_speakers is not None:
kwargs["max_speakers"] = max_speakers
diarization = pipeline(str(audio_path), **kwargs)
segments = result.get("segments", [])
for seg in segments:
seg_start = float(seg.get("start", 0))
seg_end = float(seg.get("end", 0))
speaker_times: Dict[str, float] = {}
for turn, _, speaker in diarization.itertracks(yield_label=True):
overlap_start = max(seg_start, turn.start)
overlap_end = min(seg_end, turn.end)
if overlap_end > overlap_start:
speaker_times[speaker] = speaker_times.get(speaker, 0.0) + (overlap_end - overlap_start)
seg["speaker"] = max(speaker_times, key=speaker_times.get) if speaker_times else "UNKNOWN"
lines = []
current_speaker: Optional[str] = None
current_texts: list = []
for seg in segments:
speaker = seg.get("speaker", "UNKNOWN")
text = seg.get("text", "").strip()
if not text:
continue
if speaker != current_speaker:
if current_texts and current_speaker is not None:
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
current_speaker = speaker
current_texts = [text]
else:
current_texts.append(text)
if current_texts and current_speaker is not None:
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
result["text"] = "\n".join(lines)
result["diarized"] = True
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8001)
args = parser.parse_args()
ensure_runtime_dirs()
uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
import argparse
import gc
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
import sys
sys.path.insert(0, "/app")
from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs
app = FastAPI(title="ASR Qwen3 Worker")
_PIPE_CACHE: Dict[str, Any] = {}
LANG_MAP = {
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
"fr": "french", "de": "german", "es": "spanish", "ru": "russian",
"vi": "vietnamese", "th": "thai", "ar": "arabic", "pt": "portuguese",
"it": "italian", "nl": "dutch", "pl": "polish", "tr": "turkish",
}
def _log(msg: str) -> None:
print(f"[qwen3] {msg}", flush=True)
def _free_gpu(*objs: Any) -> None:
for obj in objs:
try:
del obj
except Exception:
pass
gc.collect()
try:
import torch
if DEVICE == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
def _load_pipe(model_id: str) -> Any:
if model_id in _PIPE_CACHE:
return _PIPE_CACHE[model_id]
import torch
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
_log(f"loading model {model_id}")
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
try:
model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id,
torch_dtype=dtype,
low_cpu_mem_usage=True,
cache_dir=str(MODEL_CACHE),
)
model.to(DEVICE)
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
pipe = pipeline(
"automatic-speech-recognition",
model=model,
tokenizer=processor.tokenizer,
feature_extractor=processor.feature_extractor,
torch_dtype=dtype,
device=DEVICE,
)
except Exception:
# Fallback: let pipeline handle loading (uses device_map)
_log("direct load failed, trying pipeline auto-load")
pipe = pipeline(
"automatic-speech-recognition",
model=model_id,
torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
device_map="auto" if DEVICE == "cuda" else None,
model_kwargs={"cache_dir": str(MODEL_CACHE)},
)
_PIPE_CACHE[model_id] = pipe
_log(f"model {model_id} loaded")
return pipe
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "device": DEVICE, "loaded_models": list(_PIPE_CACHE.keys())}
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
model: str = Form("Qwen/Qwen3-ASR-2B"),
language: Optional[str] = Form("ko"),
task: str = Form("transcribe"),
) -> JSONResponse:
ensure_runtime_dirs()
suffix = Path(file.filename or "audio.bin").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = Path(tmp.name)
try:
pipe = _load_pipe(model)
lang_code = (language or "ko").strip().lower()
lang_name = LANG_MAP.get(lang_code, lang_code)
generate_kwargs: Dict[str, Any] = {"task": task}
if lang_code:
generate_kwargs["language"] = lang_name
_log(f"transcribing language={lang_code} model={model}")
raw = pipe(
str(tmp_path),
return_timestamps=True,
generate_kwargs=generate_kwargs,
)
# raw may be {"text": "...", "chunks": [...]} or {"text": "..."}
full_text: str = raw.get("text", "").strip()
chunks: List[Dict[str, Any]] = raw.get("chunks", [])
segments = []
for i, chunk in enumerate(chunks):
ts = chunk.get("timestamp") or (None, None)
start = float(ts[0]) if ts[0] is not None else None
end = float(ts[1]) if ts[1] is not None else None
segments.append({
"id": i,
"start": round(start, 3) if start is not None else None,
"end": round(end, 3) if end is not None else None,
"text": chunk.get("text", "").strip(),
})
# Estimate duration from last segment end
duration = None
if segments and segments[-1]["end"] is not None:
duration = segments[-1]["end"]
return JSONResponse({
"backend": "qwen3",
"model": model,
"language": lang_code,
"duration": duration,
"text": full_text,
"segments": segments,
"diarized": False,
})
except HTTPException:
raise
except Exception as e:
_log(f"error: {type(e).__name__}: {e}")
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
finally:
tmp_path.unlink(missing_ok=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8004)
args = parser.parse_args()
_log(f"starting host={args.host} port={args.port} device={DEVICE}")
uvicorn.run(app, host=args.host, port=args.port)