a minimal async audio transcription service. upload an audio file, get back a full transcript and a structured json report — all within a 30-minute per-user limit.
transcribify/
├── app/
│ ├── __init__.py
│ ├── config.py — env-based settings via pydantic-settings
│ ├── database.py — sqlalchemy engine, session factory, base
│ ├── models.py — user and job orm models
│ ├── auth.py — jwt creation, bcrypt verification, get_current_user dep
│ ├── providers.py — abstract interfaces + groq whisper and openrouter impls
│ └── main.py — fastapi app, routes, background task
├── tests/
│ ├── __init__.py
│ └── test_main.py — pytest suite (all mocked, no real api calls)
├── .env.example
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.md
| method | path | description | auth required |
|---|---|---|---|
POST |
/auth/register |
register a new user | — |
POST |
/auth/login |
get a jwt token | — |
POST |
/jobs |
upload audio for transcription | ✓ |
GET |
/jobs/:id |
get job status / result | ✓ |
GET |
/me/usage |
check your audio usage | ✓ |
pending → processing → done / failed
the job response when done looks like this:
{
"id": 1,
"status": "done",
"filename": "meeting.mp3",
"duration_seconds": 182.5,
"transcript": "hello everyone, welcome to the weekly sync...",
"report": {
"summary": "weekly team sync discussing q2 targets and product roadmap",
"key_points": ["q2 revenue target increased by 15%", "new feature launch scheduled for july"],
"topics": ["revenue", "product roadmap", "team updates"],
"sentiment": "positive"
},
"error": null,
"created_at": "2024-01-15T10:30:00"
}requires docker and docker compose.
# 1. clone and enter the repo
git clone <repo-url>
cd transcribify
# 2. create your env file
cp .env.example .env
# edit .env — set SECRET_KEY, GROQ_API_KEY, OPENROUTER_API_KEY
# 3. create a data directory for the sqlite db
mkdir -p data
# 4. build and start
docker compose up --buildthe service will be available at http://localhost:8000.
interactive docs: http://localhost:8000/docs.
requires python 3.11+ and ffmpeg installed on your system.
# install ffmpeg (ubuntu/debian)
sudo apt-get install ffmpeg
# install ffmpeg (macos)
brew install ffmpeg
# create and activate a virtual environment
python -m venv .venv
source .venv/bin/activate # on windows: .venv\Scripts\activate
# install dependencies
pip install -r requirements.txt
# create your env file
cp .env.example .env
# edit .env and fill in your keys
# start the server
uvicorn app.main:app --reloadtests use a separate sqlite db and mock all external calls — no api keys or internet needed.
pytest tests/ -v| variable | description | default |
|---|---|---|
SECRET_KEY |
jwt signing secret | change-me-in-production |
GROQ_API_KEY |
groq api key for whisper transcription | — |
OPENROUTER_API_KEY |
openrouter api key for llm report | — |
DATABASE_URL |
sqlalchemy sqlite connection string | sqlite:///./transcribify.db |
AUDIO_LIMIT_SECONDS |
per-user audio limit in seconds | 1800 (30 min) |
ACCESS_TOKEN_EXPIRE_MINUTES |
jwt token lifetime | 1440 (24 h) |
для транскрипции я выбрала groq whisper, потому что он полностью бесплатный, работает невероятно быстро и отлично справляется с распознаванием. для llm я использовала openrouter (модель gemma 4 31b от google), так как это дает бесплатный доступ к очень мощной модели, которая стабильно возвращает валидный и строго структурированный json, что критически важно для нашей задачи.
базовые роуты и структуру бд я сгенерировала через ии. в процессе я заметила критическую ошибку: ассистент предложил использовать синхронную библиотеку requests для отправки аудиофайла провайдеру транскрипции прямо внутри асинхронного эндпоинта fastapi. при реальной нагрузке это полностью заблокировало бы event loop. я исправила это, переписав все внешние api-вызовы на aiohttp, чтобы сервис оставался по-настоящему асинхронным. также пришлось руками переписывать фикстуры в pytest, так как ии пытался стучаться в реальные api вместо корректного использования unittest.mock.patch.
полная последовательность запросов через curl:
# 1. регистрация
curl -s -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret123"}'
# ответ:
# {"id": 1, "email": "user@example.com"}
# 2. логин — получаем токен
curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret123"}'
# ответ:
# {"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer"}
# сохраняем токен в переменную
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# 3. загрузка аудиофайла
curl -s -X POST http://localhost:8000/jobs \
-H "Authorization: Bearer $TOKEN" \
-F "file=@meeting.mp3"
# ответ:
# {"id": 1, "status": "pending", "filename": "meeting.mp3", "duration_seconds": 182.5}
# 4. проверка статуса (сразу — ещё обрабатывается)
curl -s http://localhost:8000/jobs/1 \
-H "Authorization: Bearer $TOKEN"
# ответ:
# {"id": 1, "status": "processing", "filename": "meeting.mp3", "transcript": null, "report": null, ...}
# через несколько секунд — джоба готова
curl -s http://localhost:8000/jobs/1 \
-H "Authorization: Bearer $TOKEN"
# ответ:
# {
# "id": 1,
# "status": "done",
# "filename": "meeting.mp3",
# "duration_seconds": 182.5,
# "transcript": "hello everyone, let's kick off our weekly sync...",
# "report": {
# "summary": "weekly team sync covering q2 targets and upcoming product release",
# "key_points": ["q2 revenue target increased by 15%", "new feature launch in july"],
# "topics": ["revenue", "product roadmap", "team updates"],
# "sentiment": "positive"
# },
# "error": null,
# "created_at": "2024-06-07T10:30:00"
# }
# 5. проверка использованного лимита
curl -s http://localhost:8000/me/usage \
-H "Authorization: Bearer $TOKEN"
# ответ:
# {
# "used_seconds": 182.5,
# "limit_seconds": 1800,
# "remaining_seconds": 1617.5,
# "used_minutes": 3.04,
# "limit_minutes": 30.0
# }
# 6. что будет при превышении лимита
curl -s -X POST http://localhost:8000/jobs \
-H "Authorization: Bearer $TOKEN" \
-F "file=@very_long_audio.mp3"
# ответ (если файл не помещается в остаток):
# {"detail": "audio limit exceeded. you have 60s remaining out of 1800s total"}