Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,40 @@
# audio configuration
# Copy to deploy/.env and fill ALL required values.
# Service must FAIL to start if required vars are missing.
# =============================================================================
# REQUIRED (no defaults)
# =============================================================================
# Whisper model name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
AUDIO_MODEL=large-v3-turbo
# Device: cuda or cpu
AUDIO_DEVICE=cuda
# Model cache directory (shared with other modules)
AUDIO_CACHE_DIR=/cai2_ds_storage/hf_cache
# =============================================================================
# Optional Configuration
# =============================================================================
# Compute type: float16, int8, int8_float16 (int8 recommended for GPU)
AUDIO_COMPUTE_TYPE=int8
# Transcription settings
# AUDIO_BEAM_SIZE=5
# AUDIO_BEST_OF=5
# AUDIO_TEMPERATURE=0.0
# Server settings
# AUDIO_HOST=0.0.0.0
# AUDIO_PORT=8200
# AUDIO_LOG_LEVEL=INFO
# Upload limits (in MB)
# AUDIO_MAX_FILE_SIZE_MB=500
# =============================================================================
# Optional Nginx Timeouts (for api-nginx profile)
# =============================================================================
# NGINX_CONNECT_TIMEOUT=60s
# NGINX_SEND_TIMEOUT=300s
# NGINX_READ_TIMEOUT=600s

View file

@ -0,0 +1,453 @@
# Audio Transcription API Documentation
OpenAI-compatible speech-to-text transcription API using faster-whisper.
## Base URL
```
{BASE_URL}
```
- **Local development:** `http://localhost:8200`
- **Docker (internal):** `http://audio-api:8200`
- **Direct:** `http://localhost:54300`
- **Production:** Use your configured hostname
## Authentication
No authentication required by default. Can be added via nginx or API gateway if needed.
## API Endpoints
### Health Check
Check API health status.
**Endpoint:** `GET /health`
**Response:**
```json
{
"status": "ok"
}
```
**Example:**
```bash
curl http://localhost:8200/health
```
---
### List Models
List available Whisper models (OpenAI-compatible).
**Endpoint:** `GET /v1/models`
**Response:**
```json
{
"object": "list",
"data": [
{
"id": "large-v3-turbo",
"object": "model",
"created": 1700000000,
"owned_by": "openai"
}
]
}
```
**Example:**
```bash
curl http://localhost:8200/v1/models
```
---
### Create Transcription
Transcribe audio file to text (OpenAI-compatible endpoint).
**Endpoint:** `POST /v1/audio/transcriptions`
**Content-Type:** `multipart/form-data`
**Request Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `file` | file | Yes | - | Audio file to transcribe (MP3, WAV, M4A, etc.) |
| `model` | string | No | `large-v3-turbo` | Model to use (currently ignored, uses configured model) |
| `language` | string | No | `null` | Language code (ISO-639-1). Auto-detected if not specified. |
| `prompt` | string | No | `null` | Optional text to guide the model's style |
| `response_format` | string | No | `json` | Format: `json`, `text`, or `verbose_json` |
| `temperature` | float | No | `0.0` | Sampling temperature (0.0-1.0). Use 0.0 for deterministic output. |
**Supported Languages (ISO-639-1 codes):**
`en`, `es`, `fr`, `de`, `it`, `pt`, `nl`, `pl`, `tr`, `ru`, `ja`, `ko`, `zh`, `ar`, `hi`, and 90+ more languages.
**Response Formats:**
#### 1. JSON (default)
```json
{
"text": "Full transcription text",
"language": "en",
"duration": 45.5
}
```
#### 2. Text
```
Full transcription text
```
#### 3. Verbose JSON
```json
{
"text": "Full transcription text",
"language": "en",
"duration": 45.5,
"segments": [
{
"id": 0,
"seek": 0,
"start": 0.0,
"end": 3.5,
"text": "Hello, world!",
"tokens": [15496, 11, 1002, 0],
"temperature": 0.0,
"avg_logprob": -0.25,
"compression_ratio": 1.5,
"no_speech_prob": 0.01
}
]
}
```
**Examples:**
**Basic transcription (JSON):**
```bash
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@audio.mp3" \
-F "response_format=json"
```
**With language specification:**
```bash
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@audio.mp3" \
-F "language=en" \
-F "response_format=json"
```
**Text format:**
```bash
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@audio.mp3" \
-F "response_format=text"
```
**Verbose JSON with segments:**
```bash
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@audio.mp3" \
-F "response_format=verbose_json"
```
**With initial prompt (to guide style):**
```bash
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@audio.mp3" \
-F "prompt=This is a technical discussion about machine learning." \
-F "response_format=json"
```
**Python example:**
```python
import requests
url = "http://localhost:8200/v1/audio/transcriptions"
with open("audio.mp3", "rb") as f:
files = {"file": f}
data = {
"model": "large-v3-turbo",
"language": "en",
"response_format": "verbose_json"
}
response = requests.post(url, files=files, data=data)
result = response.json()
print(f"Transcription: {result['text']}")
print(f"Duration: {result['duration']}s")
print(f"Segments: {len(result['segments'])}")
```
**JavaScript example:**
```javascript
const formData = new FormData();
formData.append('file', audioFile);
formData.append('model', 'large-v3-turbo');
formData.append('response_format', 'json');
const response = await fetch('http://localhost:8200/v1/audio/transcriptions', {
method: 'POST',
body: formData
});
const result = await response.json();
console.log('Transcription:', result.text);
```
---
## Error Responses
### Standard Error Format
```json
{
"detail": "Error message"
}
```
### Common HTTP Status Codes
| Code | Meaning | Description |
|------|---------|-------------|
| 200 | OK | Request successful |
| 400 | Bad Request | Invalid request parameters |
| 413 | Payload Too Large | File exceeds max size limit (default 500MB) |
| 422 | Unprocessable Entity | Invalid file format or corrupted audio |
| 500 | Internal Server Error | Transcription failed |
| 503 | Service Unavailable | Model not loaded or GPU unavailable |
**Example error response:**
```json
{
"detail": "File too large: 550.0MB (max: 500MB)"
}
```
---
## Rate Limiting
No built-in rate limiting. Can be added via nginx or API gateway.
**Recommended nginx configuration:**
```nginx
limit_req_zone $binary_remote_addr zone=audio_limit:10m rate=10r/m;
location / {
limit_req zone=audio_limit burst=5;
proxy_pass http://audio-api:8200;
}
```
---
## File Size Limits
- **Default:** 500MB per file
- **Configurable via:** `AUDIO_MAX_FILE_SIZE_MB` environment variable
- **Recommended:** Keep files under 100MB for best performance
---
## Supported Audio Formats
All formats supported by FFmpeg, including:
**Audio files:**
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA, OPUS
**Video files (audio track extraction):**
- MP4, AVI, MKV, MOV, WEBM, FLV
**Professional formats:**
- PCM, AIFF, AU, AMR
---
## Performance Considerations
### Processing Time
Approximate transcription times for large-v3-turbo with int8 on H200 GPU:
| Audio Length | Processing Time | Real-Time Factor |
|--------------|-----------------|------------------|
| 1 minute | ~3-5 seconds | 0.05-0.08x |
| 10 minutes | ~30-50 seconds | 0.05-0.08x |
| 1 hour | ~3-5 minutes | 0.05-0.08x |
**Real-Time Factor (RTF):** Processing time / Audio duration. Lower is better.
- RTF < 0.1x = Excellent (8x faster than real-time)
- RTF < 0.5x = Good (2x faster than real-time)
- RTF = 1.0x = Real-time
### Optimization Tips
1. **Use language parameter** when known (skip auto-detection)
2. **Use appropriate model size** based on accuracy needs
3. **Process shorter segments** for long recordings (split at silence)
4. **Monitor GPU utilization** with `nvidia-smi`
---
## Comparison with OpenAI API
This API is **fully compatible** with OpenAI's transcription endpoint:
| Feature | OpenAI | This API |
|---------|--------|----------|
| Endpoint | `/v1/audio/transcriptions` | `/v1/audio/transcriptions` ✅ |
| Request format | `multipart/form-data` | `multipart/form-data` ✅ |
| Response formats | `json`, `text`, `verbose_json` | `json`, `text`, `verbose_json` ✅ |
| Language detection | Auto | Auto ✅ |
| Max file size | 25MB | 500MB (configurable) ✅ |
| Cost | $0.006/minute | Free (self-hosted) ✅ |
| Latency | Variable | ~0.05-0.08x RTF ✅ |
**Drop-in replacement:** Change only the base URL to switch from OpenAI to this API.
---
## SDK Examples
### OpenAI Python SDK
```python
from openai import OpenAI
# Point to local API
client = OpenAI(
api_key="not-needed", # No auth required
base_url="http://localhost:8200/v1"
)
with open("audio.mp3", "rb") as f:
transcript = client.audio.transcriptions.create(
model="large-v3-turbo",
file=f,
response_format="verbose_json"
)
print(transcript.text)
```
### OpenAI Node.js SDK
```javascript
import OpenAI from 'openai';
import fs from 'fs';
const openai = new OpenAI({
apiKey: 'not-needed',
baseURL: 'http://localhost:8200/v1'
});
const transcription = await openai.audio.transcriptions.create({
file: fs.createReadStream('audio.mp3'),
model: 'large-v3-turbo',
response_format: 'verbose_json'
});
console.log(transcription.text);
```
---
## Advanced Features
### Voice Activity Detection (VAD)
Enabled by default. Automatically skips silence for:
- Faster processing
- Better accuracy
- Smaller output
### Beam Search
Configurable via `AUDIO_BEAM_SIZE` (default: 5). Higher values = better accuracy but slower.
### Temperature Sampling
- `0.0` (default): Deterministic output
- `0.0-1.0`: More creative/diverse outputs (less reliable)
---
## Monitoring
### Health Check
```bash
# Check if API is ready
curl http://localhost:8200/health
# Expected response
{"status": "ok"}
```
### Logs
```bash
# View real-time logs
cd deploy/
docker compose logs -f audio-api
# Check for errors
docker compose logs audio-api | grep ERROR
```
### GPU Usage
```bash
# Monitor GPU while transcribing
watch -n 1 nvidia-smi
# Check VRAM usage
nvidia-smi --query-gpu=memory.used,memory.total --format=csv
```
---
## Troubleshooting
### Error: File too large
**Solution:** Increase max file size or split audio into smaller chunks:
```bash
# Increase limit
AUDIO_MAX_FILE_SIZE_MB=1000
# Or split with ffmpeg
ffmpeg -i long_audio.mp3 -f segment -segment_time 600 -c copy chunk_%03d.mp3
```
### Error: Transcription failed
**Possible causes:**
1. Corrupted audio file - verify with media player
2. Unsupported format - convert to MP3/WAV
3. GPU out of memory - use smaller model or CPU mode
4. Audio is pure noise/music - Whisper is designed for speech
### Slow performance
**Check:**
1. GPU is being used: `docker exec audio-api nvidia-smi`
2. Model is correct: Check logs for model loading messages
3. Compute type is int8: Faster than float16
---
For more information, see [README.md](README.md) and the [faster-whisper documentation](https://github.com/SYSTRAN/faster-whisper).

View file

@ -0,0 +1,137 @@
# Audio Transcription Service (M17-Whisper)
Audio transcription service for DIDI media analysis. Whisper-based (M17-Whisper, faster-whisper backend), used by agent-v3 media-preprocess worker for video/audio session pipelines (techniques, ai-tampered, claims). OpenAI-compatible API — drop-in for `client.audio.transcriptions.create()`.
## Stack
- Python 3.10+ / FastAPI / Uvicorn
- Backend: `faster-whisper` >= 1.0.0 (CTranslate2 optimized inference)
- Default model: `large-v3-turbo` (809M params, ~6GB VRAM int8)
- GPU: CUDA (shared GPU 0 with Qwen3.5-35B-A3B)
- URL (Dev): `http://10.11.10.17:54300/v1/audio/transcriptions`
- Container: `didiAI-audio-api` (GPU host)
- Auth: none on the service itself; agent-v3 uses bearer token via `M17_WHISPER_TOKEN` (enforced by gateway/nginx if configured)
## Ce face
Speech-to-text transcription pe fişiere audio sau URL-uri. agent-v3 trimite fie audio buffer (multipart upload), fie URL public (din MinIO), primeşte text + metadata (lang detect, duration, optional segments). Folosit în:
- **Video analysis pipeline** — agent-v3 extrage track-ul audio cu ffmpeg, trimite la M17-Whisper, foloseşte transcript pentru `claims` + `techniques` + `ai-tampered`
- **Audio-only sessions** — direct upload, transcripted, apoi pipeline normal de analiză text
- **Cascade fallback** — agent-v3 `transcription.ts` are cascadă: M17-Whisper (local GPU) → Groq Whisper → OpenAI Whisper. Dacă local pică sau întoarce string gol, trece la următorul provider.
## API endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/health` | Health probe (returns `{"status":"ok"}`) |
| `GET` | `/v1/models` | List models, OpenAI-compatible |
| `POST` | `/v1/audio/transcriptions` | Transcribe audio (OpenAI-compatible) |
| `GET` | `/v1/info` | Service catalog metadata (used by didi catalog-api) |
### `POST /v1/audio/transcriptions`
Content-Type: `multipart/form-data`
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `file` | file | — | Audio file (MP3/WAV/M4A/FLAC/OGG/MP4 audio track…). XOR with `url`. |
| `url` | string | — | URL to download audio from (DIDI extension over OpenAI). XOR with `file`. |
| `model` | string | configured | Ignored — server uses `AUDIO_MODEL` env. |
| `language` | string | auto | ISO-639-1 code (`en`, `ro`, `es`, …). Auto-detect if omitted. |
| `prompt` | string | none | Optional initial prompt to bias style/vocab. |
| `response_format` | string | `json` | `json` \| `text` \| `verbose_json` (segments). |
| `temperature` | float | `0.0` | Sampling temperature 0.01.0. |
Response (json): `{ text, language, duration }` — verbose_json adds `segments[]` with `start/end/text/tokens/avg_logprob/no_speech_prob`.
Errors: `400` invalid params / download failed, `413` payload too large (default 500MB), `422` corrupted audio, `500` transcription failed, `503` model not loaded.
## Backends / Models
- Production: `large-v3-turbo` (8x faster than `large-v3`, similar quality, ~6GB VRAM int8)
- Available: `tiny`, `base`, `small`, `medium`, `large-v3`, `large-v3-turbo`
- VAD (Voice Activity Detection) enabled by default — skip silence
- Quantization: `int8` (recommended), `float16`, `int8_float16`
## How didi-backend uses it
- agent-v3 cascade: `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
- Order: **M17-Whisper (local GPU)** → Groq Whisper → OpenAI Whisper
- Empty-string from local triggers retry on next provider (Faza 1 din SAFETY_NETS_PLAN)
- Configured în agent-v3 docker-compose env:
- `M17_WHISPER_URL=http://10.11.10.17:54300`
- `M17_WHISPER_TOKEN=<gateway-token>` (optional, dacă există proxy auth)
- Apelat din **media-preprocess worker** la sesiuni cu `media_type` în `{audio, video}` — transcript devine input pentru `claims-routes.ts`, `routes.ts` (techniques), `ai-tampered-routes.ts`.
- Video flow: ffmpeg extract → upload temp / pass buffer → POST `/v1/audio/transcriptions` → text → pipeline analysis.
## Configuration
Env vars (prefix `AUDIO_`, set in `deploy/.env`):
**Required**
- `AUDIO_MODEL` — Whisper model (default `large-v3-turbo`)
- `AUDIO_DEVICE``cuda` | `cpu`
- `AUDIO_CACHE_DIR` — HuggingFace model cache path (`/cai2_ds_storage/hf_cache` typical)
- `AUDIO_EXTERNAL_URL` — used in OpenAPI servers spec
**Optional**
- `AUDIO_COMPUTE_TYPE``int8` (default), `float16`, `int8_float16`
- `AUDIO_BEAM_SIZE` — beam search width (default 5)
- `AUDIO_BEST_OF` — sampling candidates (default 5)
- `AUDIO_TEMPERATURE` — default 0.0
- `AUDIO_HOST` / `AUDIO_PORT` — default `0.0.0.0:54300`
- `AUDIO_LOG_LEVEL` — default `INFO`
- `AUDIO_MAX_FILE_SIZE_MB` — upload cap (default 500)
- `CUDA_VISIBLE_DEVICES=0` — pinned to GPU 0
## Deployment
Compose dir: `/home/admin365/didi_mono/ai_platform/modules/audio/deploy/`
```bash
cd modules/audio/deploy
cp ../.env.example .env # edit values
./deploy.sh --profile api --detach # API only
./deploy.sh --profile api-nginx --detach # with nginx reverse proxy
./deploy.sh --profile api --logs # tail logs
docker compose restart audio-api # quick restart
```
- Container name: `didiAI-audio-api`
- Image: `didiai-audio-api`
- Network: `didi-network` (external, shared with other AI modules)
- GPU reservation: NVIDIA driver, device `0`
- Healthcheck: HTTP `GET /health` every 30s, 60s start period (model load)
- Model cache mount: `${AUDIO_CACHE_DIR}:/root/.cache/huggingface`
## Performance
- Real-Time Factor on H200 GPU + int8 + `large-v3-turbo`: **~0.050.08x** (10s audio = 0.51s transcription, 1h audio = 35min)
- VRAM: ~6GB for large-v3-turbo int8, ~8GB float16
- Throughput: sequential — process files one at a time per GPU
- File size cap: 500MB default (configurable)
- Recommended: pre-split audio > 1h cu ffmpeg segments
## Architecture (source layout)
```
modules/audio/
├── deploy/
│ ├── deploy.sh # CLI wrapper
│ ├── docker-compose.yml # didiAI-audio-api service
│ ├── Dockerfile # CUDA + faster-whisper image
│ └── .env # runtime config
├── src/audio/
│ ├── app.py # FastAPI routes + /v1/info catalog
│ ├── transcriber.py # faster-whisper wrapper (singleton)
│ ├── schemas.py # TranscriptionResponse / Segment
│ └── settings.py # pydantic-settings (AUDIO_* env)
├── tests/
├── pyproject.toml # fastapi, faster-whisper, httpx
├── README.md # full operator guide
├── API.md # full HTTP API reference
└── INDEX.md # this file
```
## Related
- agent-v3 transcription cascade — `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
- agent-v3 media routes — `routes.ts` / `ai-tampered-routes.ts` / `claims-routes.ts`
- media-preprocess worker (in agent-v3) — invokes this service
- Test files in MinIO (used during refactor verification):
- Audio: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883851173-audio_with_voice.mp3`
- Video: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883852186-voice_video.mp4`
- Faza 1 safety net (transcription retry on empty) — `agent-v3/SAFETY_NETS_PLAN.md`
- Upstream refs: [faster-whisper](https://github.com/SYSTRAN/faster-whisper), [OpenAI Whisper](https://github.com/openai/whisper)

View file

@ -0,0 +1,289 @@
# Audio Transcription
Speech-to-text transcription service using faster-whisper for optimized inference.
## Features
- **High-Performance Transcription**: Uses faster-whisper (4x faster than original Whisper)
- **Multiple Model Sizes**: Support for tiny, base, small, medium, large-v2, large-v3, and large-v3-turbo
- **GPU Acceleration**: Optimized CUDA inference with int8 quantization
- **OpenAI-Compatible API**: Drop-in replacement for OpenAI's transcription endpoint
- **Multiple Output Formats**: JSON, text, and verbose JSON with segments
- **Language Detection**: Automatic language detection for 99+ languages
- **VAD Filtering**: Voice Activity Detection for improved accuracy
## Prerequisites
**Required (all modules):**
- All global prerequisites (see main [README.md](../../README.md))
- NVIDIA GPU with CUDA support (recommended for production)
- ~6GB VRAM for large-v3-turbo with int8 quantization
**Optional:**
- CPU-only mode available (slower but no GPU required)
> **Note:** The audio module runs on GPU 0 (shared with Qwen3.5-35B-A3B).
## Installation
```bash
cd modules/audio
# Install dependencies
uv sync
# Install with dev dependencies
uv sync --extra dev
```
## Quick Start
### As an API Server
```bash
cd deploy/
# Create deployment env file
cp ../.env.example .env
# Edit deploy/.env with your configuration
# Start the server
./deploy.sh --profile api --detach
# Or with nginx reverse proxy
./deploy.sh --profile api-nginx --detach
```
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/v1/models` | GET | List available models |
| `/v1/audio/transcriptions` | POST | Transcribe audio (OpenAI-compatible) |
### Example API Request
```bash
# Health check
curl http://localhost:8200/health
# Transcribe audio file
AUDIO="/path/to/audio.mp3"
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@${AUDIO}" \
-F "model=large-v3-turbo" \
-F "response_format=json"
# With language specification and verbose output
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
-F "file=@${AUDIO}" \
-F "language=en" \
-F "response_format=verbose_json"
```
### Example Response
**JSON format:**
```json
{
"text": "This is the full transcription of your audio file.",
"language": "en",
"duration": 45.5
}
```
**Verbose JSON format:**
```json
{
"text": "This is the full transcription.",
"language": "en",
"duration": 45.5,
"segments": [
{
"id": 0,
"start": 0.0,
"end": 3.5,
"text": "This is the full transcription.",
"tokens": [123, 456, 789],
"temperature": 0.0,
"avg_logprob": -0.25,
"compression_ratio": 1.5,
"no_speech_prob": 0.01
}
]
}
```
## Configuration
### Required Environment Variables
Configured via environment variables (prefix: `AUDIO_`). These are typically set in `deploy/.env`:
| Variable | Description |
|----------|-------------|
| `AUDIO_MODEL` | Whisper model name (e.g., `large-v3-turbo`) |
| `AUDIO_DEVICE` | Device: `cuda` or `cpu` |
| `AUDIO_CACHE_DIR` | Model cache directory |
### Optional Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `AUDIO_COMPUTE_TYPE` | `int8` | Compute type: `float16`, `int8`, `int8_float16` |
| `AUDIO_BEAM_SIZE` | `5` | Beam size for decoding (1-10) |
| `AUDIO_BEST_OF` | `5` | Number of candidates when sampling |
| `AUDIO_TEMPERATURE` | `0.0` | Sampling temperature (0.0-1.0) |
| `AUDIO_LOG_LEVEL` | `INFO` | Log level |
| `AUDIO_MAX_FILE_SIZE_MB` | `500` | Max upload file size in MB |
### Available Models
| Model | Parameters | VRAM (int8) | Speed | Quality |
|-------|-----------|-------------|-------|---------|
| `tiny` | 39M | ~1GB | 10x | Basic |
| `base` | 74M | ~1GB | 7x | Good |
| `small` | 244M | ~2GB | 4x | Better |
| `medium` | 769M | ~3-4GB | 2x | Very Good |
| `large-v3` | 1550M | ~6-8GB | 1x | Excellent |
| `large-v3-turbo` | 809M | ~6GB | 8x | Excellent |
**Recommendation:** Use `large-v3-turbo` for best balance of speed and accuracy.
## Deployment
```bash
cd deploy/
# Create deployment env file (REQUIRED)
cp ../.env.example .env
# Edit deploy/.env with your settings
# API only
./deploy.sh --profile api --detach
# API with nginx reverse proxy
./deploy.sh --profile api-nginx --detach
# View logs
./deploy.sh --profile api --logs
# Stop services
./deploy.sh --profile api --down
```
### Common Docker Commands
```bash
cd deploy/
# Restart the API service
docker compose restart audio-api
# Rebuild after code changes
docker compose build audio-api
docker compose up -d audio-api
# Check logs
docker compose logs -f audio-api
```
### Port Allocation
| Port | Service |
|------|---------|
| `8200` | Audio API |
| `54300` | Audio API (Dev + AI + Audio) |
## Development
```bash
# Install dev dependencies
uv sync --extra dev
# Run tests
uv run pytest
# Run tests with coverage
uv run pytest --cov=src/audio --cov-report=term-missing
# Lint and format
uv run ruff check .
uv run ruff format .
```
## Architecture
```
modules/audio/
├── deploy/
│ ├── deploy.sh # Deployment script
│ ├── docker-compose.yml # Docker services
│ ├── Dockerfile # Container image
│ └── nginx.conf # Nginx reverse proxy config (optional)
├── src/audio/
│ ├── __init__.py
│ ├── app.py # FastAPI application
│ ├── transcriber.py # faster-whisper wrapper
│ ├── schemas.py # Response schemas
│ └── settings.py # Configuration
├── tests/
├── .env.example # Environment template
├── API.md # API documentation
├── pyproject.toml # Dependencies
└── README.md # This file
```
## Supported Audio Formats
faster-whisper (via FFmpeg) supports:
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA
- MP4, AVI, MKV (audio track extraction)
- And many more formats supported by FFmpeg
## Performance Tips
1. **Use int8 quantization** for GPU inference (40% memory savings, minimal accuracy loss)
2. **Use large-v3-turbo** for best speed/accuracy tradeoff (8x faster than large-v3)
3. **Enable VAD filtering** (enabled by default) to skip silence
4. **Batch processing**: Process multiple files sequentially for better GPU utilization
5. **Language specification**: Specify language code when known for faster processing
## Troubleshooting
### Issue: Out of Memory
**Solution:** Use a smaller model or reduce compute type:
```bash
# Use medium model instead
AUDIO_MODEL=medium
# Or use float16 instead of int8 (uses more VRAM but may work better)
AUDIO_COMPUTE_TYPE=float16
```
### Issue: Slow Transcription
**Solution:**
- Ensure GPU is being used (`AUDIO_DEVICE=cuda`)
- Use int8 compute type for faster inference
- Use large-v3-turbo instead of large-v3
- Check GPU utilization with `nvidia-smi`
### Issue: Model Download Fails
**Solution:** Check network connectivity and cache directory permissions:
```bash
# Verify cache directory exists and is writable
ls -la /cai2_ds_storage/hf_cache
# Or change to local directory
AUDIO_CACHE_DIR=/home/user/.cache/huggingface
```
## References
- [faster-whisper GitHub](https://github.com/SYSTRAN/faster-whisper)
- [OpenAI Whisper](https://github.com/openai/whisper)
- [Whisper Model Card](https://github.com/openai/whisper/blob/main/model-card.md)

View file

@ -0,0 +1,40 @@
# Audio Transcription Service - Dockerfile
# Uses faster-whisper for optimized speech-to-text transcription
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
# Prevent interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Bucharest
# Install Python 3.10 (default in Ubuntu 22.04) and system dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-dev \
curl \
ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/python3 /usr/bin/python
# Set working directory
WORKDIR /app
# Copy project files
COPY pyproject.toml /app/
COPY src/ /app/src/
# Install Python dependencies
RUN pip install --no-cache-dir -e .
# Create cache directory
RUN mkdir -p /root/.cache/huggingface
# Default port
ENV AUDIO_PORT=54300
# Expose port
EXPOSE 54300
# Run FastAPI with uvicorn
CMD python -m uvicorn audio.app:app --host 0.0.0.0 --port ${AUDIO_PORT}

View file

@ -0,0 +1,116 @@
#!/usr/bin/env bash
#
# Docker Compose Startup Script for Audio Transcription
#
# Usage: ./deploy/deploy.sh [OPTIONS]
#
# Options:
# --profile <api|api-nginx> Docker compose profile
# --detach Run in detached mode
# --down Stop and remove containers
# --logs Show logs
# --help Show this help message
#
# Required: Set environment variables in deploy/.env file or export them before running.
# See ../.env.example (module root) for the full list of variables.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load .env file ONLY from deploy/ directory
if [[ -f "$SCRIPT_DIR/.env" ]]; then
echo "Loading environment from: $SCRIPT_DIR/.env"
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
PROFILE=""
DETACH=""
ACTION="up"
show_help() {
sed -n '2,18p' "$0" | sed 's/^# //' | sed 's/^#//'
exit 0
}
check_required_var() {
local var_name="$1"
if [[ -z "${!var_name:-}" ]]; then
echo "ERROR: Required environment variable $var_name is not set"
echo "Set it in deploy/.env file or export it before running this script"
exit 1
fi
}
while [[ $# -gt 0 ]]; do
case $1 in
--profile)
PROFILE="$2"
shift 2
;;
--detach|-d)
DETACH="-d"
shift
;;
--down)
ACTION="down"
shift
;;
--logs)
ACTION="logs"
shift
;;
--help|-h)
show_help
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Require profile when bringing up
if [[ -z "$PROFILE" && "$ACTION" == "up" ]]; then
echo "ERROR: --profile is required"
echo "Options: api, api-nginx"
exit 1
fi
# Fail-fast required vars
check_required_var "AUDIO_MODEL"
check_required_var "AUDIO_DEVICE"
check_required_var "AUDIO_CACHE_DIR"
cd "$SCRIPT_DIR"
case $ACTION in
up)
echo "Starting Audio Transcription API with profile: $PROFILE"
echo " Model: $AUDIO_MODEL"
echo " Device: $AUDIO_DEVICE"
echo " Compute type: ${AUDIO_COMPUTE_TYPE:-int8}"
echo " Cache dir: $AUDIO_CACHE_DIR"
echo ""
# shellcheck disable=SC2086
exec docker compose --profile "$PROFILE" up $DETACH
;;
down)
if [[ -z "$PROFILE" ]]; then
echo "ERROR: --profile is required with --down"
exit 1
fi
echo "Stopping Audio Transcription containers..."
exec docker compose --profile "$PROFILE" down
;;
logs)
if [[ -z "$PROFILE" ]]; then
echo "ERROR: --profile is required with --logs"
exit 1
fi
exec docker compose --profile "$PROFILE" logs -f
;;
esac

View file

@ -0,0 +1,95 @@
# Audio Module - Docker Compose Configuration
#
# Port Allocation (Dev AI Audio: 54300):
# 54300 - Audio API (Whisper STT service)
#
# Profiles:
# api - API server only
#
# Required environment variables (set in deploy/.env file):
# AUDIO_MODEL - Whisper model name (e.g., large-v3-turbo)
# AUDIO_DEVICE - Device: cuda or cpu
# AUDIO_COMPUTE_TYPE - Compute type: int8, float16, int8_float16
# AUDIO_CACHE_DIR - Model cache directory
#
# GPU Configuration:
# - Runs on GPU 0 (shared with Qwen3.5-35B-A3B)
# - Requires ~6GB VRAM for large-v3-turbo with int8
#
# Naming Convention: didiAI-{module}-{service}
#
# Network:
# Uses deploy_default network (shared with other modules)
networks:
deploy_default:
external: true
services:
# ==========================================================================
# Audio Transcription API Server
# ==========================================================================
audio-api:
container_name: didiAI-audio-api
image: didiai-audio-api
build:
context: ..
dockerfile: deploy/Dockerfile
ports:
- "54300:54300"
networks:
- deploy_default
environment:
# GPU configuration
- CUDA_VISIBLE_DEVICES=0
# External URL for OpenAPI spec (REQUIRED)
- AUDIO_EXTERNAL_URL=${AUDIO_EXTERNAL_URL}
# Whisper model configuration
- AUDIO_MODEL=${AUDIO_MODEL:-large-v3-turbo}
- AUDIO_DEVICE=${AUDIO_DEVICE:-cuda}
- AUDIO_COMPUTE_TYPE=${AUDIO_COMPUTE_TYPE:-int8}
- AUDIO_CACHE_DIR=${AUDIO_CACHE_DIR:-/root/.cache/huggingface}
# Transcription settings
- AUDIO_BEAM_SIZE=${AUDIO_BEAM_SIZE:-5}
- AUDIO_BEST_OF=${AUDIO_BEST_OF:-5}
- AUDIO_TEMPERATURE=${AUDIO_TEMPERATURE:-0.0}
# Server settings
- AUDIO_HOST=0.0.0.0
- AUDIO_PORT=54300
- AUDIO_LOG_LEVEL=${AUDIO_LOG_LEVEL:-INFO}
# Runtime config polling
- AUDIO_DASHBOARD_URL=${AUDIO_DASHBOARD_URL:-http://didiAI-dashboard:51300}
# Upload limits
- AUDIO_MAX_FILE_SIZE_MB=${AUDIO_MAX_FILE_SIZE_MB:-500}
volumes:
# Model cache (shared with other modules)
- ${AUDIO_CACHE_DIR:-/root/.cache/huggingface}:/root/.cache/huggingface
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ['0']
capabilities: [gpu]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:54300/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
profiles:
- api

View file

@ -0,0 +1,34 @@
[project]
name = "audio"
version = "0.1.0"
description = "Speech-to-text transcription service using faster-whisper"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"python-multipart>=0.0.9",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"faster-whisper>=1.0.0",
"prometheus-fastapi-instrumentator>=7.0.0",
"opentelemetry-instrumentation-fastapi>=0.50b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=4.0",
"ruff>=0.8",
"httpx>=0.27.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/audio"]
[tool.ruff]
extend = "../../ruff.toml"

View file

@ -0,0 +1,3 @@
"""Audio transcription service using faster-whisper."""
__version__ = "0.1.0"

View file

@ -0,0 +1,523 @@
import logging
import tempfile
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import httpx
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse
from .runtime_config import RuntimeConfigClient
from .schemas import TranscriptionResponse, TranscriptionSegment
from .settings import settings
from .transcriber import get_transcriber
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Runtime config (instantiated here so it's reachable from request handlers via app.state)
runtime_config = RuntimeConfigClient(
dashboard_url=settings.dashboard_url,
live_log_logger_name="audio",
live_log_key="audio.log.level",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""App lifespan: load Whisper model + start runtime config polling."""
logger.info("Starting Audio Transcription API")
logger.info(f"Model: {settings.model}")
logger.info(f"Device: {settings.device}")
# Initialize transcriber (loads model)
get_transcriber()
await runtime_config.start()
app.state.runtime_config = runtime_config
logger.info("Startup complete")
yield
logger.info("Shutting down Audio Transcription API")
await runtime_config.stop()
app = FastAPI(
title="Audio Transcription API",
description="Speech-to-text transcription using faster-whisper",
version="0.1.0",
servers=[{"url": settings.external_url, "description": "Audio Transcription API"}],
lifespan=lifespan,
)
# ---------------------------------------------------------------- observability
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
try:
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
except ImportError:
pass
import os as _os # noqa: E402
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if _otel_ep:
try:
from opentelemetry import trace as _trace # type: ignore
from opentelemetry.sdk.resources import Resource as _R # type: ignore
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-audio-api")}))
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
_trace.set_tracer_provider(_provider)
_FInst.instrument_app(app)
print(f"[otel] didiAI-audio-api instrumented -> {_otel_ep}")
except ImportError as _e:
print(f"[otel] skip: {_e}")
@app.get("/health")
def health():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/v1/models")
def list_models():
"""List available models (OpenAI-compatible)."""
return {
"object": "list",
"data": [
{
"id": settings.model,
"object": "model",
"created": 1700000000,
"owned_by": "openai",
}
],
}
async def _download_url(url: str) -> tuple[bytes, str]:
"""Download audio from URL. Returns (content, file_extension)."""
parsed = urlparse(url)
suffix = Path(parsed.path).suffix or ".mp3"
async with httpx.AsyncClient(follow_redirects=True, timeout=120.0) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.content, suffix
async def _get_audio_content(
file: Optional[UploadFile],
url: Optional[str],
) -> tuple[bytes, str]:
"""Get audio content from file upload or URL. Returns (content, suffix)."""
if file and url:
raise HTTPException(
status_code=400,
detail="Provide either 'file' or 'url', not both.",
)
if url:
try:
content, suffix = await _download_url(url)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=400,
detail=f"Failed to download URL ({e.response.status_code}): {url}",
)
except httpx.RequestError as e:
raise HTTPException(
status_code=400,
detail=f"Failed to download URL: {e}",
)
elif file:
content = await file.read()
suffix = Path(file.filename).suffix if file.filename else ".mp3"
else:
raise HTTPException(
status_code=400,
detail="Either 'file' or 'url' is required.",
)
file_size_mb = len(content) / (1024 * 1024)
if file_size_mb > settings.max_file_size_mb:
raise HTTPException(
status_code=413,
detail=f"File too large: {file_size_mb:.1f}MB (max: {settings.max_file_size_mb}MB)",
)
return content, suffix
@app.post("/v1/audio/transcriptions")
async def create_transcription(
file: Optional[UploadFile] = File(default=None),
url: Optional[str] = Form(default=None),
model: str = Form(default=None),
language: str = Form(default=None),
prompt: str = Form(default=None),
response_format: str = Form(default="json"),
temperature: float = Form(default=0.0),
):
"""
Transcribe audio file (OpenAI-compatible endpoint).
Accepts either a file upload or a URL to download.
Args:
file: Audio file to transcribe (multipart upload)
url: URL to download audio from (alternative to file upload)
model: Model to use (ignored, uses configured model)
language: Language code (auto-detect if None)
prompt: Optional text prompt
response_format: Response format: json, text, or verbose_json
temperature: Sampling temperature
Returns:
Transcription response in requested format
"""
content, suffix = await _get_audio_content(file, url)
# Save to temp location
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
tmp_file.write(content)
tmp_path = tmp_file.name
try:
# Transcribe
transcriber = get_transcriber()
full_text, metadata = transcriber.transcribe(
audio_path=tmp_path,
language=language if language else None,
initial_prompt=prompt if prompt else None,
temperature=temperature,
)
# Format response based on response_format
if response_format == "text":
return PlainTextResponse(content=full_text)
elif response_format == "verbose_json":
# Verbose format with segments
segments = [TranscriptionSegment(**seg) for seg in metadata["segments"]]
response = TranscriptionResponse(
text=full_text,
language=metadata["language"],
duration=metadata["duration"],
segments=segments,
)
return JSONResponse(content=response.model_dump())
else:
# Default JSON format (text only)
response = TranscriptionResponse(
text=full_text,
language=metadata["language"],
duration=metadata["duration"],
)
return JSONResponse(content=response.model_dump())
except HTTPException:
raise
except Exception as e:
logger.error(f"Transcription error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}")
finally:
# Clean up temp file
try:
Path(tmp_path).unlink()
except Exception:
pass
@app.get("/v1/info")
def get_component_info():
"""
Get component information for service catalog.
Returns complete metadata about this service including:
- Resource information (component metadata)
- Available models (Whisper variants)
- Available functions (API endpoints)
This endpoint is used by the catalog-api to aggregate service information
and by backend systems to populate the catalog database.
Returns:
dict: Component information matching catalog.resources, catalog.models,
and catalog.functions schemas.
"""
# Build resource information (maps to catalog.resources)
resource = {
"name": "Audio Transcription Service",
"slug": "audio-transcription",
"resource_type": "api_service",
"provider": "internal",
"base_url": f"http://audio-api:{settings.port}",
"configuration": {
"version": "0.1.0",
"port": settings.port,
"external_url": "http://localhost:8203",
"model": settings.model,
"device": settings.device,
"compute_type": settings.compute_type,
},
"authentication": {
"type": "none",
"required": False,
},
"headers": {
"Content-Type": "multipart/form-data",
"Accept": "application/json",
},
"rate_limits": {
"enabled": False,
},
"cost_tracking": {
"enabled": False,
},
"tags": ["audio", "transcription", "whisper", "speech-to-text", "openai-compatible"],
"is_active": True,
"metadata": {
"category": "audio",
"gpu_required": settings.device == "cuda",
"status": "healthy",
"backend": "faster-whisper",
"max_file_size_mb": settings.max_file_size_mb,
},
}
# Define models (maps to catalog.models)
models = [
{
"name": f"Whisper {settings.model}",
"slug": f"whisper-{settings.model}",
"provider": "openai",
"model_type": "audio",
"capabilities": [
"transcription",
"translation",
"language-detection",
"voice-activity-detection",
],
"configuration": {
"backend": "faster-whisper",
"device": settings.device,
"compute_type": settings.compute_type,
"beam_size": settings.beam_size,
"best_of": settings.best_of,
},
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
"api_key_ref": None,
"tags": ["whisper", "audio", "transcription", settings.model, settings.device],
"is_active": True,
"metadata": {
"model_name": settings.model,
"cache_dir": settings.cache_dir,
"gpu_id": 1 if settings.device == "cuda" else None,
"vram_gb": 7 if settings.device == "cuda" and "large" in settings.model else None,
"quantization": settings.compute_type,
},
}
]
# Define available functions (maps to catalog.functions)
functions = [
{
"name": "Audio Transcription",
"slug": "audio-transcription",
"category": "transcription",
"description": (
"Transcribe audio files to text using Whisper. "
"OpenAI-compatible endpoint supporting multiple response formats."
),
"input_schema": {
"type": "object",
"properties": {
"file": {
"type": "file",
"description": "Audio file to transcribe",
},
"model": {
"type": "string",
"description": "Model identifier (ignored, uses configured model)",
},
"language": {
"type": "string",
"description": "Language code (ISO 639-1). Auto-detect if not specified.",
},
"prompt": {
"type": "string",
"description": "Optional text prompt to guide the model",
},
"response_format": {
"type": "string",
"enum": ["json", "text", "verbose_json"],
"default": "json",
"description": "Response format",
},
"temperature": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"default": 0.0,
"description": "Sampling temperature",
},
},
"required": ["file"],
},
"output_schema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Transcribed text"},
"language": {"type": "string", "description": "Detected language"},
"duration": {"type": "number", "description": "Audio duration in seconds"},
"segments": {
"type": "array",
"description": "Transcription segments (only in verbose_json format)",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"start": {"type": "number"},
"end": {"type": "number"},
"text": {"type": "string"},
},
},
},
},
},
"implementation": {
"method": "POST",
"path": "/v1/audio/transcriptions",
"content_type": "multipart/form-data",
"timeout": 300,
"max_file_size_mb": settings.max_file_size_mb,
},
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
"tags": ["audio", "transcription", "openai-compatible"],
"is_active": True,
"metadata": {
"openai_compatible": True,
"supports_streaming": False,
},
},
{
"name": "List Audio Models",
"slug": "audio-list-models",
"category": "discovery",
"description": "List available Whisper models (OpenAI-compatible)",
"input_schema": {
"type": "object",
"properties": {},
},
"output_schema": {
"type": "object",
"properties": {
"object": {"type": "string", "const": "list"},
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"object": {"type": "string"},
"created": {"type": "integer"},
"owned_by": {"type": "string"},
},
},
},
},
},
"implementation": {
"method": "GET",
"path": "/v1/models",
"timeout": 5,
},
"endpoint": "http://localhost:8203/v1/models",
"tags": ["discovery", "models"],
"is_active": True,
"metadata": {},
},
]
return {
"resource": resource,
"models": models,
"functions": functions,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"audio.app:app",
host=settings.host,
port=settings.port,
log_level=settings.log_level.lower(),
)

View file

@ -0,0 +1,157 @@
"""Runtime config client — fetches config overrides from dashboard.
Polls the dashboard /api/config endpoint periodically and caches values
in memory. All accessors fall back to caller-supplied defaults if the
dashboard is unreachable or the key is missing.
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
local default registry; consumers pass their own fallback (typically the
pydantic settings value).
"""
import asyncio
import contextlib
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class RuntimeConfigClient:
"""Polls dashboard /api/config and caches values in-process."""
def __init__(
self,
dashboard_url: str | None,
poll_interval_seconds: int = 30,
request_timeout: float = 5.0,
live_log_logger_name: str | None = None,
live_log_key: str | None = None,
) -> None:
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
self.poll_interval = poll_interval_seconds
self._timeout = request_timeout
self._cache: dict[str, Any] = {}
self._client: httpx.AsyncClient | None = None
self._task: asyncio.Task[None] | None = None
self._enabled = bool(dashboard_url)
# Optional auto-apply: re-set log level on the named logger when the
# configured key changes value (e.g., `llm.log.level`).
self._live_log_logger_name = live_log_logger_name
self._live_log_key = live_log_key
self._last_log_level: str | None = None
@property
def enabled(self) -> bool:
return self._enabled
def get(self, key: str, default: Any = None) -> Any:
v = self._cache.get(key)
return v if v is not None else default
def get_bool(self, key: str, default: bool = False) -> bool:
v = self._cache.get(key)
return bool(v) if v is not None else default
def get_int(self, key: str, default: int = 0) -> int:
v = self._cache.get(key)
if v is None:
return default
try:
return int(v)
except (TypeError, ValueError):
return default
def get_float(self, key: str, default: float = 0.0) -> float:
v = self._cache.get(key)
if v is None:
return default
try:
return float(v)
except (TypeError, ValueError):
return default
def get_str(self, key: str, default: str = "") -> str:
v = self._cache.get(key)
return str(v) if v is not None else default
async def start(self) -> None:
if not self._enabled:
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
return
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0, read=self._timeout, write=2.0, pool=5.0
)
)
# Fetch once synchronously so first requests already have overrides
await self._refresh()
self._task = asyncio.create_task(self._loop())
logger.info(
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
self.dashboard_url,
self.poll_interval,
len(self._cache),
)
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _loop(self) -> None:
while True:
try:
await asyncio.sleep(self.poll_interval)
await self._refresh()
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Config poll failed: %s", e)
async def _refresh(self) -> None:
if self._client is None or self.dashboard_url is None:
return
try:
resp = await self._client.get(f"{self.dashboard_url}/api/config")
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.debug("Config refresh failed: %s", e)
return
items = data.get("items", {})
new_cache: dict[str, Any] = {}
for key, entry in items.items():
new_cache[key] = entry.get("value")
self._cache = new_cache
self._maybe_apply_log_level()
def _maybe_apply_log_level(self) -> None:
"""Re-apply log level live if configured key changed."""
if not self._live_log_key or not self._live_log_logger_name:
return
new_level = self.get_str(self._live_log_key)
if not new_level:
return
if new_level == self._last_log_level:
return
try:
level_int = logging.getLevelName(new_level.upper())
if isinstance(level_int, int):
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
self._last_log_level = new_level
logger.info(
"Log level for %s changed to %s (via runtime config)",
self._live_log_logger_name,
new_level,
)
except Exception as e:
logger.warning("Failed to apply log level %s: %s", new_level, e)

View file

@ -0,0 +1,44 @@
from typing import Literal, Optional
from pydantic import BaseModel, Field
class TranscriptionSegment(BaseModel):
"""A segment of transcribed audio."""
id: int = Field(..., description="Segment ID")
seek: int = Field(..., description="Seek position in audio")
start: float = Field(..., description="Start time in seconds")
end: float = Field(..., description="End time in seconds")
text: str = Field(..., description="Transcribed text")
tokens: list[int] = Field(..., description="Token IDs")
temperature: float = Field(..., description="Temperature used")
avg_logprob: float = Field(..., description="Average log probability")
compression_ratio: float = Field(..., description="Compression ratio")
no_speech_prob: float = Field(..., description="No speech probability")
class TranscriptionResponse(BaseModel):
"""OpenAI-compatible transcription response."""
text: str = Field(..., description="Full transcription text")
language: Optional[str] = Field(None, description="Detected language code")
duration: Optional[float] = Field(None, description="Audio duration in seconds")
segments: Optional[list[TranscriptionSegment]] = Field(None, description="Detailed segments")
class TranscriptionRequest(BaseModel):
"""Transcription request parameters."""
model: str = Field(default="large-v3-turbo", description="Whisper model to use")
language: Optional[str] = Field(None, description="Language code (auto-detect if None)")
prompt: Optional[str] = Field(None, description="Optional text prompt")
response_format: Literal["json", "text", "verbose_json"] = Field(
default="json",
description="Response format"
)
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Sampling temperature")
timestamp_granularities: list[Literal["segment", "word"]] = Field(
default=["segment"],
description="Timestamp granularities"
)

View file

@ -0,0 +1,47 @@
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="AUDIO_",
extra="ignore",
)
# Whisper model configuration
model: str = Field(default="large-v3-turbo", description="Whisper model name")
device: str = Field(default="cuda", description="Device: cuda or cpu")
compute_type: str = Field(default="int8", description="Compute type: float16, int8, int8_float16")
# Model cache
cache_dir: str = Field(default="/root/.cache/huggingface", description="Model cache directory")
# Transcription settings
beam_size: int = Field(default=5, ge=1, le=10, description="Beam size for decoding")
best_of: int = Field(default=5, ge=1, le=10, description="Number of candidates when sampling")
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Temperature for sampling")
# Server settings
host: str = Field(default="0.0.0.0")
port: int = Field(default=54300)
log_level: str = Field(default="INFO")
external_url: str = Field(
description="External URL for OpenAPI spec (e.g., http://10.11.10.42:54300). REQUIRED.",
)
# Upload limits
max_file_size_mb: int = Field(default=500, description="Max upload file size in MB")
# Runtime config (dashboard polling)
dashboard_url: str | None = Field(
default=None,
description=(
"Optional dashboard base URL. When set, runtime_config polls "
"/api/config every 30s for live overrides."
),
)
settings = Settings()

View file

@ -0,0 +1,106 @@
import logging
import tempfile
from pathlib import Path
from typing import Optional
from faster_whisper import WhisperModel
from .settings import settings
logger = logging.getLogger(__name__)
class Transcriber:
"""Wrapper for faster-whisper transcription."""
def __init__(self):
"""Initialize the Whisper model."""
logger.info(f"Loading Whisper model: {settings.model}")
logger.info(f"Device: {settings.device}, Compute type: {settings.compute_type}")
self.model = WhisperModel(
settings.model,
device=settings.device,
compute_type=settings.compute_type,
download_root=settings.cache_dir,
)
logger.info("Whisper model loaded successfully")
def transcribe(
self,
audio_path: str | Path,
language: Optional[str] = None,
initial_prompt: Optional[str] = None,
temperature: float = 0.0,
) -> tuple[str, dict]:
"""
Transcribe audio file.
Args:
audio_path: Path to audio file
language: Language code (None for auto-detection)
initial_prompt: Optional prompt text
temperature: Sampling temperature
Returns:
Tuple of (full_text, metadata_dict)
"""
logger.info(f"Transcribing: {audio_path}")
segments, info = self.model.transcribe(
str(audio_path),
language=language,
initial_prompt=initial_prompt,
beam_size=settings.beam_size,
best_of=settings.best_of,
temperature=temperature,
vad_filter=True,
vad_parameters=dict(min_silence_duration_ms=500),
)
# Collect segments
all_segments = []
full_text_parts = []
for segment in segments:
all_segments.append({
"id": segment.id,
"seek": segment.seek,
"start": segment.start,
"end": segment.end,
"text": segment.text,
"tokens": segment.tokens,
"temperature": segment.temperature,
"avg_logprob": segment.avg_logprob,
"compression_ratio": segment.compression_ratio,
"no_speech_prob": segment.no_speech_prob,
})
full_text_parts.append(segment.text)
full_text = "".join(full_text_parts).strip()
metadata = {
"language": info.language,
"language_probability": info.language_probability,
"duration": info.duration,
"duration_after_vad": info.duration_after_vad,
"all_language_probs": info.all_language_probs,
"segments": all_segments,
}
logger.info(f"Transcription complete: {len(all_segments)} segments, {info.duration:.2f}s")
return full_text, metadata
# Global transcriber instance
_transcriber: Optional[Transcriber] = None
def get_transcriber() -> Transcriber:
"""Get or create the global transcriber instance."""
global _transcriber
if _transcriber is None:
_transcriber = Transcriber()
return _transcriber