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

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)