Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
40
ai_platform/modules/audio/.env.example
Normal file
40
ai_platform/modules/audio/.env.example
Normal 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
|
||||
453
ai_platform/modules/audio/API.md
Normal file
453
ai_platform/modules/audio/API.md
Normal 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).
|
||||
137
ai_platform/modules/audio/INDEX.md
Normal file
137
ai_platform/modules/audio/INDEX.md
Normal 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.0–1.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.05–0.08x** (10s audio = 0.5–1s transcription, 1h audio = 3–5min)
|
||||
- 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)
|
||||
289
ai_platform/modules/audio/README.md
Normal file
289
ai_platform/modules/audio/README.md
Normal 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)
|
||||
40
ai_platform/modules/audio/deploy/Dockerfile
Normal file
40
ai_platform/modules/audio/deploy/Dockerfile
Normal 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}
|
||||
116
ai_platform/modules/audio/deploy/deploy.sh
Normal file
116
ai_platform/modules/audio/deploy/deploy.sh
Normal 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
|
||||
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal file
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal 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
|
||||
|
||||
34
ai_platform/modules/audio/pyproject.toml
Normal file
34
ai_platform/modules/audio/pyproject.toml
Normal 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"
|
||||
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Audio transcription service using faster-whisper."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
523
ai_platform/modules/audio/src/audio/app.py
Normal file
523
ai_platform/modules/audio/src/audio/app.py
Normal 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(),
|
||||
)
|
||||
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal file
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal 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)
|
||||
44
ai_platform/modules/audio/src/audio/schemas.py
Normal file
44
ai_platform/modules/audio/src/audio/schemas.py
Normal 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"
|
||||
)
|
||||
47
ai_platform/modules/audio/src/audio/settings.py
Normal file
47
ai_platform/modules/audio/src/audio/settings.py
Normal 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()
|
||||
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal file
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal 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
|
||||
0
ai_platform/modules/audio/tests/__init__.py
Normal file
0
ai_platform/modules/audio/tests/__init__.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue