Initial commit
This commit is contained in:
6
transcription-api/.dockerignore
Normal file
6
transcription-api/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
__pycache__/
|
||||
.ruff_cache
|
||||
.venv
|
||||
.vscode
|
||||
whisper_models/
|
||||
21
transcription-api/Dockerfile
Normal file
21
transcription-api/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -qq update \
|
||||
&& apt-get -qq install --no-install-recommends \
|
||||
ffmpeg curl\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip3 install -r requirements.txt
|
||||
RUN pip3 install python-multipart
|
||||
|
||||
COPY . .
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s \
|
||||
CMD ["python3", "healthcheck.py"]
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python3", "main.py"]
|
||||
68
transcription-api/backends/backend.py
Normal file
68
transcription-api/backends/backend.py
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
from typing import Any, Mapping, TypedDict
|
||||
import numpy as np
|
||||
from faster_whisper.audio import decode_audio # type: ignore
|
||||
|
||||
SUPPORTED_MODELS = ["tiny", "tiny.en", "small", "small.en", "base", "base.en", "medium", "medium.en", "large-v2"]
|
||||
|
||||
WordData = TypedDict(
|
||||
"WordData", {"word": str, "start": float | str, "end": float | str, "score": float}
|
||||
)
|
||||
|
||||
Segment = TypedDict(
|
||||
"Segment",
|
||||
{
|
||||
"id": str,
|
||||
"text": str,
|
||||
"start": float | str,
|
||||
"end": float | str,
|
||||
"score": float,
|
||||
"words": list[WordData],
|
||||
},
|
||||
)
|
||||
|
||||
Transcription = TypedDict(
|
||||
"Transcription",
|
||||
{
|
||||
"text": str,
|
||||
"language": str,
|
||||
"duration": float,
|
||||
"segments": list[Segment],
|
||||
},
|
||||
)
|
||||
|
||||
class Backend:
|
||||
name: str = "faster-whisper"
|
||||
model_size: str
|
||||
|
||||
def __post_init__(self):
|
||||
if self.model_size not in self.supported_model_sizes():
|
||||
raise ValueError(f"model must be one of {self.supported_model_sizes()}")
|
||||
|
||||
def supported_backends(self):
|
||||
"""
|
||||
This is of not much use as of the moment, If we ever support multiple
|
||||
backends this can be utilized.
|
||||
"""
|
||||
return ["faster-whisper"]
|
||||
|
||||
def model_path(self) -> str:
|
||||
"""
|
||||
Returns the local path to the model, error-out if unavailable
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def supported_model_sizes(self) -> list[str]:
|
||||
return SUPPORTED_MODELS
|
||||
|
||||
def download_model(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def load(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def transcribe(self, input: np.ndarray) -> list[Segment]:
|
||||
"""
|
||||
This should return word level transcription data.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
97
transcription-api/backends/fasterwhisper.py
Normal file
97
transcription-api/backends/fasterwhisper.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import numpy as np
|
||||
from .backend import Backend, Transcription, Segment
|
||||
import os, math
|
||||
from tqdm import tqdm # type: ignore
|
||||
import uuid
|
||||
from faster_whisper import WhisperModel, download_model, decode_audio
|
||||
|
||||
class FasterWhisperBackend(Backend):
|
||||
device: str = "cpu" # cpu, cuda
|
||||
quantization: str = "int8" # int8, float16
|
||||
model: WhisperModel | None = None
|
||||
|
||||
def __init__(self, model_size, device: str = "cpu"):
|
||||
self.model_size = model_size
|
||||
self.device = device
|
||||
self.__post_init__()
|
||||
|
||||
def model_path(self) -> str:
|
||||
local_model_path = os.path.join(
|
||||
os.environ["WHISPER_MODELS_DIR"], f"faster-whisper-{self.model_size}"
|
||||
)
|
||||
|
||||
if os.path.exists(local_model_path):
|
||||
return local_model_path
|
||||
else:
|
||||
raise RuntimeError(f"model not found in {local_model_path}")
|
||||
|
||||
def load(self) -> None:
|
||||
self.model = WhisperModel(
|
||||
self.model_path(), device=self.device, compute_type=self.quantization
|
||||
)
|
||||
|
||||
def get_model(self) -> None:
|
||||
print(f"Downloading model {self.model_size}...")
|
||||
local_model_path = os.path.join(os.environ["WHISPER_MODELS_DIR"], f"faster-whisper-{self.model_size}")
|
||||
local_model_cache = os.path.join(os.environ["WHISPER_MODELS_DIR"], f"faster-whisper-{self.model_size}", "cache")
|
||||
# Check if directory exists
|
||||
if not os.path.exists(local_model_path):
|
||||
os.makedirs(local_model_path)
|
||||
try:
|
||||
download_model(self.model_size, output_dir=local_model_path, local_files_only=True, cache_dir=local_model_cache)
|
||||
print("Model already cached...")
|
||||
except:
|
||||
download_model(self.model_size, output_dir=local_model_path, local_files_only=False, cache_dir=local_model_cache)
|
||||
|
||||
def transcribe(
|
||||
self, input: np.ndarray, silent: bool = False, language: str = None
|
||||
) -> Transcription:
|
||||
"""
|
||||
Return word level transcription data.
|
||||
World level probabities are calculated by ctranslate2.models.Whisper.align
|
||||
"""
|
||||
result: list[Segment] = []
|
||||
assert self.model is not None
|
||||
segments, info = self.model.transcribe(
|
||||
input,
|
||||
beam_size=5,
|
||||
word_timestamps=True,
|
||||
language=language,
|
||||
)
|
||||
# ps = playback seconds
|
||||
with tqdm(
|
||||
total=info.duration, unit_scale=True, unit="ps", disable=silent
|
||||
) as pbar:
|
||||
for segment in segments:
|
||||
if segment.words is None:
|
||||
continue
|
||||
id = uuid.uuid4().hex
|
||||
segment_extract: Segment = {
|
||||
"id": id,
|
||||
"text": segment.text,
|
||||
"start": segment.start,
|
||||
"end": segment.end,
|
||||
"score": round(math.exp(segment.avg_logprob), 2),
|
||||
"words": [
|
||||
{
|
||||
"start": w.start,
|
||||
"end": w.end,
|
||||
"word": w.word,
|
||||
"score": round(w.probability, 2),
|
||||
}
|
||||
for w in segment.words
|
||||
],
|
||||
}
|
||||
result.append(segment_extract)
|
||||
if not silent:
|
||||
pbar.update(segment.end - pbar.last_print_n)
|
||||
|
||||
text = " ".join([segment["text"] for segment in result])
|
||||
text = ' '.join(text.strip().split())
|
||||
transcription: Transcription = {
|
||||
"text": text,
|
||||
"language": info.language,
|
||||
"duration": info.duration,
|
||||
"segments": result,
|
||||
}
|
||||
return transcription
|
||||
11
transcription-api/docker-compose.yml
Normal file
11
transcription-api/docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
whisper-api:
|
||||
container_name: whisper-api
|
||||
build: .
|
||||
ports:
|
||||
- "8088:8000"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
6
transcription-api/healthcheck.py
Normal file
6
transcription-api/healthcheck.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
url='http://0.0.0.0:8000/healthcheck/',
|
||||
timeout=60
|
||||
)
|
||||
43
transcription-api/main.py
Normal file
43
transcription-api/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, UploadFile, File
|
||||
from models import ModelSize, Languages, DeviceType
|
||||
from transcribe import transcribe_file, transcribe_from_filename
|
||||
import uvicorn
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
from backends.fasterwhisper import FasterWhisperBackend
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/transcribe/")
|
||||
async def transcribe_endpoint(file: UploadFile = File(None),
|
||||
filename: str = None,
|
||||
model_size: ModelSize = ModelSize.small,
|
||||
language: Languages = Languages.auto,
|
||||
device: DeviceType = DeviceType.cpu):
|
||||
|
||||
print(f"Transcribing with model {model_size.value} on device {device.value}...")
|
||||
if file is not None:
|
||||
# if a file is uploaded, use it
|
||||
return await transcribe_file(file, model_size.value, language.value, device.value)
|
||||
elif filename is not None:
|
||||
# if a filename is provided, use it
|
||||
return await transcribe_from_filename(filename, model_size.value, language.value, device.value)
|
||||
else:
|
||||
return {"detail": "No file uploaded and no filename provided"}
|
||||
|
||||
@app.get("/healthcheck/")
|
||||
async def healthcheck():
|
||||
return {"status": "healthy"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_dotenv()
|
||||
|
||||
# Get model list (comma separated) from environment variable
|
||||
model_list = os.environ.get("WHISPER_MODELS", "tiny,base,small")
|
||||
model_list = model_list.split(",")
|
||||
for model in model_list:
|
||||
m = FasterWhisperBackend(model_size=model)
|
||||
m.get_model()
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
45
transcription-api/models.py
Normal file
45
transcription-api/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel
|
||||
|
||||
class DeviceType(str, Enum):
|
||||
cpu = "cpu"
|
||||
cuda = "cuda"
|
||||
|
||||
class ModelSize(str, Enum):
|
||||
tiny_en = "tiny.en"
|
||||
tiny = "tiny"
|
||||
base_en = "base.en"
|
||||
base = "base"
|
||||
small_en = "small.en"
|
||||
small = "small"
|
||||
medium_en = "medium.en"
|
||||
medium = "medium"
|
||||
large_v2 = "large-v2"
|
||||
|
||||
class Languages(str, Enum):
|
||||
auto = "auto"
|
||||
ar = "ar"
|
||||
be = "be"
|
||||
bg = "bg"
|
||||
bn = "bn"
|
||||
ca = "ca"
|
||||
cs = "cs"
|
||||
cy = "cy"
|
||||
da = "da"
|
||||
de = "de"
|
||||
el = "el"
|
||||
en = "en"
|
||||
es = "es"
|
||||
fr = "fr"
|
||||
it = "it"
|
||||
jp = "jp"
|
||||
nl = "nl"
|
||||
pl = "pl"
|
||||
pt = "pt"
|
||||
ru = "ru"
|
||||
sk = "sk"
|
||||
sl = "sl"
|
||||
sv = "sv"
|
||||
tk = "tk"
|
||||
tr = "tr"
|
||||
zh = "zh"
|
||||
6
transcription-api/requirements.txt
Normal file
6
transcription-api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.100.1
|
||||
faster_whisper==0.7.1
|
||||
pydantic==2.1.1
|
||||
python-dotenv==1.0.0
|
||||
uvicorn==0.23.2
|
||||
python-multipart
|
||||
56
transcription-api/transcribe.py
Normal file
56
transcription-api/transcribe.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from backends.fasterwhisper import FasterWhisperBackend
|
||||
from backends.backend import Transcription
|
||||
from faster_whisper import decode_audio
|
||||
from models import DeviceType
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import io
|
||||
import os
|
||||
|
||||
def convert_audio(file) -> np.ndarray:
|
||||
return decode_audio(file, split_stereo=False, sampling_rate=16000)
|
||||
|
||||
async def transcribe_from_filename(filename: str,
|
||||
model_size: int,
|
||||
language: Optional[str] = None,
|
||||
device: DeviceType = DeviceType.cpu) -> Transcription:
|
||||
|
||||
filepath = os.path.join(os.environ["UPLOAD_DIR"], filename)
|
||||
if not os.path.exists(filepath):
|
||||
raise RuntimeError(f"file not found in {filepath}")
|
||||
audio = convert_audio(filepath)
|
||||
return await transcribe_audio(audio, model_size, language, device)
|
||||
|
||||
async def transcribe_file(file: io.BytesIO,
|
||||
model_size: int,
|
||||
language: Optional[str] = None,
|
||||
device: DeviceType = DeviceType.cpu) -> Transcription:
|
||||
contents = await file.read() # async read
|
||||
if len(contents) < 150 * 1024 * 1024: # file is smaller than 150MB
|
||||
audio = convert_audio(io.BytesIO(contents))
|
||||
else:
|
||||
# Save the uploaded file temporarily on disk
|
||||
with open(file.filename, 'wb') as f:
|
||||
f.write(contents)
|
||||
# Check if file exists
|
||||
if not os.path.exists(file.filename):
|
||||
raise RuntimeError(f"file not found in {file.filename}")
|
||||
# Corrected to use the function in this file
|
||||
audio = convert_audio(file.filename)
|
||||
os.remove(file.filename)
|
||||
return await transcribe_audio(audio, model_size, language, device)
|
||||
|
||||
async def transcribe_audio(audio: np.ndarray,
|
||||
model_size: int,
|
||||
language: Optional[str] = None,
|
||||
device: DeviceType = DeviceType.cpu) -> Transcription:
|
||||
|
||||
if language == "auto":
|
||||
language = None
|
||||
|
||||
# Load the model
|
||||
model = FasterWhisperBackend(model_size=model_size, device=device)
|
||||
model.get_model()
|
||||
model.load()
|
||||
# Transcribe the file
|
||||
return model.transcribe(audio, silent=True, language=language)
|
||||
Reference in New Issue
Block a user