first
This commit is contained in:
18
docker/transcription-worker/Dockerfile
Normal file
18
docker/transcription-worker/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for faster-whisper
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
125
docker/transcription-worker/main.py
Normal file
125
docker/transcription-worker/main.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Speech2Text MBIP — Transcription Worker
|
||||
FastAPI service wrapping faster-whisper for Bahasa Melayu transcription.
|
||||
Internal-only service; not exposed to internet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Speech2Text Transcription Worker", docs_url=None, redoc_url=None)
|
||||
|
||||
# Config from environment
|
||||
MODEL_SIZE = os.getenv("WHISPER_MODEL", "small")
|
||||
LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ms")
|
||||
DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
|
||||
COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
|
||||
|
||||
logger.info(f"Loading Whisper model: {MODEL_SIZE}, device: {DEVICE}, compute: {COMPUTE_TYPE}")
|
||||
|
||||
# Load model once at startup
|
||||
model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)
|
||||
|
||||
logger.info("Whisper model loaded successfully.")
|
||||
|
||||
|
||||
class TranscribeResponse(BaseModel):
|
||||
success: bool
|
||||
transcript: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "model": MODEL_SIZE, "language": LANGUAGE}
|
||||
|
||||
|
||||
@app.post("/transcribe", response_model=TranscribeResponse)
|
||||
async def transcribe_audio(audio: UploadFile = File(...)):
|
||||
"""
|
||||
Terima fail audio, transkripkan ke teks Bahasa Melayu.
|
||||
Hanya accessible dari dalam Docker network sahaja.
|
||||
"""
|
||||
# Validate content type
|
||||
allowed_types = {
|
||||
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
|
||||
"audio/mp4", "audio/x-m4a", "audio/aac", "video/mp4",
|
||||
"audio/ogg", "audio/flac", "audio/webm",
|
||||
}
|
||||
|
||||
if audio.content_type and audio.content_type not in allowed_types:
|
||||
logger.warning(f"Rejected content type: {audio.content_type}")
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported audio format: {audio.content_type}")
|
||||
|
||||
# Save to temp file
|
||||
suffix = Path(audio.filename or "audio.mp3").suffix or ".mp3"
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await audio.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
logger.info(f"Transcribing: {audio.filename}, size: {len(content)} bytes")
|
||||
|
||||
# Run transcription
|
||||
segments, info = model.transcribe(
|
||||
tmp_path,
|
||||
language=LANGUAGE,
|
||||
beam_size=5,
|
||||
vad_filter=True,
|
||||
)
|
||||
|
||||
# Collect segments
|
||||
all_text = []
|
||||
total_confidence = []
|
||||
|
||||
for segment in segments:
|
||||
all_text.append(segment.text.strip())
|
||||
# faster-whisper segments have avg_logprob; convert to 0-1 range
|
||||
if hasattr(segment, "avg_logprob") and segment.avg_logprob is not None:
|
||||
import math
|
||||
confidence = min(1.0, max(0.0, math.exp(segment.avg_logprob)))
|
||||
total_confidence.append(confidence)
|
||||
|
||||
transcript = " ".join(all_text).strip()
|
||||
avg_confidence = sum(total_confidence) / len(total_confidence) if total_confidence else None
|
||||
duration = info.duration if hasattr(info, "duration") else None
|
||||
|
||||
logger.info(f"Transcription complete: {len(transcript)} chars, lang: {info.language}")
|
||||
|
||||
return TranscribeResponse(
|
||||
success=True,
|
||||
transcript=transcript,
|
||||
language=info.language,
|
||||
confidence=round(avg_confidence, 4) if avg_confidence else None,
|
||||
duration_seconds=round(duration, 2) if duration else None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed: {e}", exc_info=True)
|
||||
return TranscribeResponse(
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Always cleanup temp file
|
||||
if "tmp_path" in locals():
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
6
docker/transcription-worker/requirements.txt
Normal file
6
docker/transcription-worker/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
faster-whisper==1.0.3
|
||||
python-multipart==0.0.9
|
||||
pydantic==2.8.2
|
||||
requests==2.32.3
|
||||
Reference in New Issue
Block a user