- рефакторинг приложения
- снова изменения в Readme - работа над валидацией параметров - большая гибкость и конфигурироемость
This commit is contained in:
5
.idea/.gitignore
generated
vendored
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
15
Dockerfile
15
Dockerfile
@@ -4,11 +4,12 @@ FROM python:3.10-slim
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies including openssl
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ffmpeg \
|
||||
git \
|
||||
curl \
|
||||
openssl \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -24,13 +25,17 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY app.py .
|
||||
|
||||
# Create directory for models and keys
|
||||
# Copy startup script for key generation
|
||||
COPY docker-entrypoint.sh .
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
# Create directory for models and data
|
||||
RUN mkdir -p /app/models /app/data
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV MODEL_DOWNLOAD_ROOT=/app/models
|
||||
ENV KEYS_FILE=/app/data/keys.txt
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=9854
|
||||
|
||||
# Expose port
|
||||
EXPOSE 9854
|
||||
@@ -40,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:9854/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "app.py"]
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
|
||||
298
README.MD
298
README.MD
@@ -1,113 +1,269 @@
|
||||
# ASR service на базе Whisper + AMD ROCm
|
||||
# Simple ASR Service на базе Whisper
|
||||
|
||||
Сервис для распознавания речи с использованием GPU AMD через ROCm.
|
||||
Простой сервис для распознавания речи с использованием OpenAI Whisper. Поддерживает различные форматы ответов, управление API-ключами без перезапуска и все параметры модели Whisper.
|
||||
|
||||
## Особенности
|
||||
|
||||
- 🎯 Эндпоинт `/transcribe` для распознавания речи
|
||||
- 🔑 Управление API-ключами без перезапуска сервиса
|
||||
- 📊 Три формата ответа: `json`, `simple`, `text`
|
||||
- ⚙️ Поддержка всех параметров `whisper.transcribe()`
|
||||
- 🐳 Docker и native запуск
|
||||
- 🏥 Health check эндпоинт
|
||||
- 🔄 Горячая перезагрузка API-ключей
|
||||
|
||||
## Требования
|
||||
|
||||
- GPU AMD с поддержкой ROCm (RX5000, RX6000 +)
|
||||
- Установленный ROCm
|
||||
- Docker + Compose plugin
|
||||
- Debian based linux (для инструкции)
|
||||
- Минимум 8GB RAM
|
||||
- Минимум 2GB свободного места + место для ROCm + Модели (turbo ~3.5GB)
|
||||
- Python 3.8+
|
||||
- FFmpeg (для обработки аудио)
|
||||
- Минимум 4GB RAM
|
||||
- Свободное место для моделей (turbo ~1GB, large ~3GB)
|
||||
|
||||
## Установка и запуск
|
||||
Для Docker дополнительно:
|
||||
- Docker + Docker Compose
|
||||
- GPU AMD с поддержкой ROCm (опционально)
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
## Быстрый старт
|
||||
|
||||
### Запуск через Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/SlavaVlad/simple-asr-server.git ./asr
|
||||
cd asr
|
||||
```
|
||||
|
||||
### 2. Создание Docker-образа
|
||||
|
||||
Перед запуском в файле docker-compose.yml можно изменить всякие переменные окружения, например модель по умолчанию.
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Тут он сам всё соберёт и запустится.
|
||||
|
||||
### Нативный запуск
|
||||
|
||||
```bash
|
||||
git clone https://github.com/SlavaVlad/simple-asr-server.git ./asr
|
||||
cd asr
|
||||
chmod +x start_server.sh
|
||||
./start_server.sh
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Создайте файл `.env` для настройки переменных окружения:
|
||||
|
||||
```env
|
||||
HOST=0.0.0.0
|
||||
PORT=9854
|
||||
DEFAULT_MODEL=turbo
|
||||
MODEL_DOWNLOAD_ROOT=./models
|
||||
KEYS_FILE=./data/keys.txt
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### Доступные модели Whisper
|
||||
|
||||
- `tiny` - самая быстрая, наименее точная
|
||||
- `base` - баланс скорости и качества
|
||||
- `small` - хорошее качество
|
||||
- `medium` - лучшее качество
|
||||
- `large` - максимальное качество
|
||||
- `turbo` - оптимизированная версия (рекомендуется)
|
||||
|
||||
## Управление API-ключами
|
||||
|
||||
При первом запуске сервиса автоматически создается файл `keys.txt` со случайно сгенерированным API-ключом.
|
||||
### Создание ключей
|
||||
|
||||
### Добавление новых ключей
|
||||
1. Откройте файл `keys.txt`
|
||||
2. Добавьте новые ключи, каждый с новой строки
|
||||
3. Перезапустите сервис
|
||||
При первом запуске автоматически создается файл `data/keys.txt` с демонстрационным ключом.
|
||||
|
||||
Пример содержимого `keys.txt`:
|
||||
```
|
||||
f7a9c2b3d4e5a6b7c8d9e0f1a2b3c4d5
|
||||
e8b9a2c3d4f5g6h7i8j9k0l1m2n3o4p5
|
||||
```
|
||||
### Добавление/удаление ключей
|
||||
|
||||
## Использование API
|
||||
|
||||
### Распознавание речи
|
||||
1. Отредактируйте файл `data/keys.txt` (один ключ на строку)
|
||||
2. Вызовите эндпоинт перезагрузки:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/transcribe" \
|
||||
-H "x-api-key: ВАШ_КЛЮЧ" \
|
||||
-F "file=@путь_к_аудио_файлу" \
|
||||
-F "model_name=turbo" # (необязательно, по умолчанию turbo)
|
||||
curl -X POST "http://localhost:9854/keys/reload" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### Ответ API
|
||||
Пример `data/keys.txt`:
|
||||
```
|
||||
key1
|
||||
key2
|
||||
```
|
||||
|
||||
## API Документация
|
||||
|
||||
### POST /transcribe
|
||||
|
||||
Основной эндпоинт для распознавания речи.
|
||||
|
||||
#### Параметры
|
||||
|
||||
**Обязательные:**
|
||||
- `audio_file` - аудиофайл (form-data)
|
||||
|
||||
**Опциональные:**
|
||||
- `format` - формат ответа: `json` (по умолчанию), `simple`, `text`
|
||||
- Все параметры `whisper.transcribe()`:
|
||||
- `language` - язык аудио (auto-detect по умолчанию)
|
||||
- `task` - `transcribe` или `translate`
|
||||
- `temperature` - температура для генерации (0.0-1.0)
|
||||
- `beam_size` - размер луча для поиска
|
||||
- `best_of` - количество кандидатов для выбора лучшего
|
||||
- `compression_ratio_threshold` - порог сжатия для фильтрации
|
||||
- `logprob_threshold` - порог логарифмической вероятности
|
||||
- `no_speech_threshold` - порог детекции отсутствия речи
|
||||
- `condition_on_previous_text` - использовать предыдущий текст как контекст
|
||||
- `initial_prompt` - начальная подсказка для модели
|
||||
- `word_timestamps` - временные метки слов (true/false)
|
||||
- `prepend_punctuations` - знаки препинания для добавления в начало
|
||||
- `append_punctuations` - знаки препинания для добавления в конец
|
||||
- `clip_timestamps` - временные метки для обрезки аудио
|
||||
- `hallucination_silence_threshold` - порог тишины для детекции галлюцинаций
|
||||
|
||||
#### Примеры запросов
|
||||
|
||||
**Простая транскрибация:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/transcribe" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
--form "audio_file=@audio.wav"
|
||||
```
|
||||
|
||||
**С параметрами:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/transcribe" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"language": "ru",
|
||||
"format": "simple",
|
||||
"word_timestamps": true,
|
||||
"temperature": 0.2
|
||||
}' \
|
||||
--form "audio_file=@audio.wav"
|
||||
```
|
||||
|
||||
**Только текст:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/transcribe" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"format": "text"}' \
|
||||
--form "audio_file=@audio.wav"
|
||||
```
|
||||
|
||||
**Расширенные параметры:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/transcribe" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"language": "en",
|
||||
"task": "translate",
|
||||
"temperature": 0.1,
|
||||
"beam_size": 5,
|
||||
"word_timestamps": true,
|
||||
"initial_prompt": "This is a technical presentation about AI",
|
||||
"format": "json"
|
||||
}' \
|
||||
--form "audio_file=@audio.wav"
|
||||
```
|
||||
|
||||
#### Форматы ответов
|
||||
|
||||
**json (полный ответ от Whisper):**
|
||||
```json
|
||||
{
|
||||
"transcription": [...],
|
||||
"text": "распознанный текст",
|
||||
"metrics": {
|
||||
"processing_time_seconds": 1.23,
|
||||
"characters_per_second": 45.6,
|
||||
"audio_realtime_ratio": 2.34,
|
||||
"audio_duration": 5.67,
|
||||
"text_length": 89
|
||||
"text": "Привет, как дела?",
|
||||
"segments": [
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 2.5,
|
||||
"text": "Привет, как дела?",
|
||||
"words": [...]
|
||||
}
|
||||
],
|
||||
"language": "ru"
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
|
||||
**simple (только текст):**
|
||||
```json
|
||||
{
|
||||
"text": "Привет, как дела?"
|
||||
}
|
||||
```
|
||||
|
||||
## Метрики производительности
|
||||
**text (plain text):**
|
||||
```
|
||||
Привет, как дела?
|
||||
```
|
||||
|
||||
API возвращает следующие метрики:
|
||||
- `processing_time_seconds`: время обработки в секундах
|
||||
- `characters_per_second`: скорость обработки (символов в секунду)
|
||||
- `audio_realtime_ratio`: отношение длительности аудио к времени обработки
|
||||
- `audio_duration`: длительность аудио в секундах
|
||||
- `text_length`: количество символов в распознанном тексте
|
||||
### GET /health
|
||||
|
||||
## Поддерживаемые форматы
|
||||
Проверка состояния сервиса:
|
||||
|
||||
Поддерживаются все аудиоформаты, которые может обработать FFmpeg. Файлы автоматически конвертируются в нужный формат.
|
||||
|
||||
## Решение проблем
|
||||
|
||||
### Проверка статуса сервиса
|
||||
```bash
|
||||
curl http://localhost:9854/docs
|
||||
curl "http://localhost:9854/health"
|
||||
```
|
||||
|
||||
### Частые проблемы
|
||||
Ответ:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"model_loaded": true,
|
||||
"model_name": "turbo"
|
||||
}
|
||||
```
|
||||
|
||||
1. **Ошибка доступа к GPU**
|
||||
```bash
|
||||
sudo usermod -a -G video,render $USER
|
||||
sudo reboot
|
||||
```
|
||||
### POST /keys/reload
|
||||
|
||||
2. **Ошибка 403**
|
||||
- Проверьте правильность API-ключа
|
||||
- Убедитесь, что ключ добавлен в файл keys.txt
|
||||
- Перезапустите сервис
|
||||
Перезагрузка API-ключей без перезапуска:
|
||||
|
||||
## Лицензия
|
||||
```bash
|
||||
curl -X POST "http://localhost:9854/keys/reload" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
MIT License
|
||||
### GET /keys/count
|
||||
|
||||
Количество активных ключей:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:9854/keys/count" \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
## Коды ошибок
|
||||
|
||||
- `401` - API ключ не предоставлен
|
||||
- `403` - Неверный API ключ
|
||||
- `422` - Неверные параметры запроса
|
||||
- `500` - Ошибка сервера/модели
|
||||
|
||||
## Systemd сервис
|
||||
|
||||
Для автоматического запуска создайте systemd сервис:
|
||||
|
||||
```bash
|
||||
sudo cp asr.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable asr
|
||||
sudo systemctl start asr
|
||||
```
|
||||
|
||||
## Поддерживаемые форматы аудио
|
||||
|
||||
Все форматы, поддерживаемые FFmpeg:
|
||||
- WAV, MP3, FLAC, M4A, OGG
|
||||
- Видео форматы (извлекается аудио): MP4, AVI, MKV
|
||||
|
||||
## Производительность
|
||||
|
||||
Время обработки зависит от:
|
||||
- Выбранной модели
|
||||
- Длительности аудио
|
||||
- Доступных ресурсов (CPU/GPU)
|
||||
|
||||
Примерные времена для 1 минуты аудио:
|
||||
- `tiny`: ~2-5 секунд
|
||||
- `turbo`: ~5-10 секунд
|
||||
- `large`: ~15-30 секунд
|
||||
|
||||
389
app.py
389
app.py
@@ -1,279 +1,176 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from os import getenv
|
||||
from typing import Dict
|
||||
import json
|
||||
import whisper
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Set, Literal, List, Union
|
||||
from threading import Lock
|
||||
|
||||
import gigaam
|
||||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Request, Form
|
||||
from fastapi.security import APIKeyHeader
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import uvicorn
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
# Pydantic модель для параметров транскрибации
|
||||
class TranscribeParams(BaseModel):
|
||||
language: Optional[str] = Field(None, description="Язык аудио (auto-detect по умолчанию)")
|
||||
task: Optional[str] = Field("transcribe", description="transcribe или translate")
|
||||
temperature: Optional[float] = Field(0.0, description="Температура для генерации (0.0-1.0)")
|
||||
beam_size: Optional[int] = Field(None, description="Размер луча для поиска")
|
||||
best_of: Optional[int] = Field(None, description="Количество кандидатов для выбора лучшего")
|
||||
compression_ratio_threshold: Optional[float] = Field(None, description="Порог сжатия для фильтрации")
|
||||
logprob_threshold: Optional[float] = Field(None, description="Порог логарифмической вероятности")
|
||||
no_speech_threshold: Optional[float] = Field(None, description="Порог детекции отсутствия речи")
|
||||
condition_on_previous_text: Optional[bool] = Field(True, description="Использовать предыдущий текст как контекст")
|
||||
initial_prompt: Optional[str] = Field(None, description="Начальная подсказка для модели")
|
||||
word_timestamps: Optional[bool] = Field(False, description="Временные метки слов")
|
||||
prepend_punctuations: Optional[str] = Field(None, description="Знаки препинания для добавления в начало")
|
||||
append_punctuations: Optional[str] = Field(None, description="Знаки препинания для добавления в конец")
|
||||
clip_timestamps: Optional[List[float]] = Field(None, description="Временные метки для обрезки аудио")
|
||||
hallucination_silence_threshold: Optional[float] = Field(None, description="Порог тишины для детекции галлюцинаций")
|
||||
format: Optional[Literal["json", "simple", "text"]] = Field("json", description="Формат ответа")
|
||||
|
||||
model = gigaam.load_model("v2_ctc", device=getenv("ASR_DEVICE"), download_root=getenv("ASR_MODELS_ROOT"))
|
||||
# Глобальные переменные для модели и ключей
|
||||
model = None
|
||||
api_keys: Set[str] = set()
|
||||
keys_lock = Lock()
|
||||
keys_file_path = os.getenv("KEYS_FILE", "keys.txt")
|
||||
|
||||
# API key header
|
||||
api_key_header = APIKeyHeader(name="x-api-key")
|
||||
# Схема безопасности
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
|
||||
app = FastAPI(title="Whisper ASR Service", version="1.0.0")
|
||||
|
||||
def get_keys(): # не бейте меня за это
|
||||
keys_file = "keys.txt"
|
||||
if not os.path.exists(keys_file):
|
||||
# Create a new keys file with a default key
|
||||
default_key = os.urandom(32).hex()
|
||||
with open(keys_file, "w") as f:
|
||||
f.write(default_key + "\n")
|
||||
logger.info(f"Created new keys file with default key: {default_key}")
|
||||
return [default_key]
|
||||
def load_api_keys():
|
||||
"""Загружает API ключи из файла"""
|
||||
global api_keys
|
||||
try:
|
||||
if os.path.exists(keys_file_path):
|
||||
with open(keys_file_path, 'r') as f:
|
||||
keys = [line.strip() for line in f.readlines() if line.strip()]
|
||||
with keys_lock:
|
||||
api_keys = set(keys)
|
||||
logger.info(f"Загружено {len(api_keys)} API ключей")
|
||||
else:
|
||||
# Read keys from the existing file
|
||||
with open(keys_file, "r") as f:
|
||||
keys = [line.strip() for line in f if line.strip()]
|
||||
logger.info(f"Loaded {len(keys)} keys from file")
|
||||
logger.debug(f"Keys: {keys}")
|
||||
if not keys:
|
||||
raise ValueError("No keys found in keys.txt")
|
||||
return keys
|
||||
|
||||
|
||||
def convert_audio(input_path: str, output_path: str, speed: float = 1.25):
|
||||
"""
|
||||
Convert audio to compatible format and speed up
|
||||
"""
|
||||
try:
|
||||
command = [
|
||||
'ffmpeg', '-i', input_path,
|
||||
'-filter:a', f'atempo={speed}',
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
'-c:a', 'pcm_s16le',
|
||||
output_path,
|
||||
'-y'
|
||||
]
|
||||
logger.debug(f"Running FFmpeg command: {' '.join(command)}")
|
||||
subprocess.run(command, check=True, capture_output=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"FFmpeg conversion failed: {e.stderr.decode()}")
|
||||
return False
|
||||
|
||||
|
||||
class TranscriptionMetrics:
|
||||
def __init__(self):
|
||||
self.start_time = time.time()
|
||||
self.end_time = None
|
||||
self.text_length = 0
|
||||
self.audio_duration = 0
|
||||
|
||||
def stop(self, text: str, audio_duration: float):
|
||||
self.end_time = time.time()
|
||||
self.text_length = len(text)
|
||||
self.audio_duration = audio_duration
|
||||
|
||||
def get_metrics(self) -> Dict[str, float]:
|
||||
processing_time = self.end_time - self.start_time
|
||||
return {
|
||||
"processing_time_seconds": round(processing_time, 2),
|
||||
"characters_per_second": round(self.text_length / processing_time, 2),
|
||||
"audio_realtime_ratio": round(self.audio_duration / processing_time, 2),
|
||||
"audio_duration": round(self.audio_duration, 2),
|
||||
"text_length": self.text_length
|
||||
}
|
||||
|
||||
|
||||
def get_audio_duration(file_path: str) -> float:
|
||||
"""Get audio duration using ffprobe"""
|
||||
cmd = [
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
file_path
|
||||
]
|
||||
try:
|
||||
output = subprocess.check_output(cmd).decode().strip()
|
||||
return float(output)
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
|
||||
@app.post("/transcribe/simple")
|
||||
async def transcribe_simple(
|
||||
file: UploadFile = File(...),
|
||||
token: str = Depends(api_key_header),
|
||||
model_name: str = "turbo"
|
||||
):
|
||||
# Token validation
|
||||
if token not in get_keys():
|
||||
logger.warning(f"Invalid token attempt: {token}")
|
||||
if token == "" or token is None:
|
||||
raise HTTPException(status_code=401, detail="Forbidden. x-api-key header is missing or empty.")
|
||||
raise HTTPException(status_code=403, detail="Forbidden. Invalid API key.")
|
||||
|
||||
logger.info(f"Processing file: {file.filename} with model: {model_name}")
|
||||
|
||||
if file.size > int(os.getenv("MAX_UPLOAD_SIZE_MB")) * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail=f'File size exceeds ${os.getenv("MAX_UPLOAD_SIZE_MB")}MB limit')
|
||||
|
||||
# Save uploaded file
|
||||
temp_input_path = f"/tmp/input_{file.filename}"
|
||||
temp_output_path = f"/tmp/converted_{file.filename}.wav"
|
||||
|
||||
try:
|
||||
with open(temp_input_path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
# Convert audio if needed
|
||||
logger.debug("Converting audio file")
|
||||
if not convert_audio(temp_input_path, temp_output_path):
|
||||
raise HTTPException(status_code=400, detail="Audio conversion failed")
|
||||
|
||||
# Get audio duration before speed up
|
||||
original_duration = get_audio_duration(temp_input_path)
|
||||
|
||||
# Transcribe
|
||||
logger.info("Starting transcription")
|
||||
if original_duration > 30:
|
||||
logger.info("Audio duration > 30 seconds, using transcribe_longform")
|
||||
transcription_result = model.transcribe_longform(
|
||||
temp_output_path
|
||||
)
|
||||
else:
|
||||
logger.info("Audio duration <= 30 seconds, using transcribe")
|
||||
transcription_result = model.transcribe(
|
||||
temp_output_path
|
||||
)
|
||||
|
||||
full_text = ""
|
||||
for part in transcription_result:
|
||||
if part["transcription"].strip() != "":
|
||||
full_text += part["transcription"].strip() + " "
|
||||
|
||||
result = full_text
|
||||
|
||||
return result
|
||||
|
||||
logger.warning(f"Файл ключей {keys_file_path} не найден")
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Ошибка загрузки ключей: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup temporary files
|
||||
if os.path.exists(temp_input_path):
|
||||
os.remove(temp_input_path)
|
||||
if os.path.exists(temp_output_path):
|
||||
os.remove(temp_output_path)
|
||||
def load_model():
|
||||
"""Загружает модель Whisper"""
|
||||
global model
|
||||
model_name = os.getenv("DEFAULT_MODEL", "turbo")
|
||||
download_root = os.getenv("MODEL_DOWNLOAD_ROOT", "./models")
|
||||
device = os.getenv("MODEL_DEVICE", "cpu")
|
||||
|
||||
try:
|
||||
logger.info(f"Загрузка модели Whisper: {model_name}")
|
||||
model = whisper.load_model(model_name, device=device, download_root=download_root)
|
||||
logger.info("Модель успешно загружена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки модели: {e}")
|
||||
raise
|
||||
|
||||
def verify_api_key(api_key: str = Depends(api_key_header)) -> str:
|
||||
"""Проверяет API ключ"""
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=401, detail="API ключ не предоставлен")
|
||||
|
||||
# Перезагружаем ключи для проверки обновлений
|
||||
load_api_keys()
|
||||
|
||||
with keys_lock:
|
||||
if api_key not in api_keys:
|
||||
raise HTTPException(status_code=403, detail="Неверный API ключ")
|
||||
|
||||
return api_key
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Инициализация при запуске"""
|
||||
load_api_keys()
|
||||
load_model()
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Проверка здоровья сервиса"""
|
||||
return {"status": "healthy", "model_loaded": model is not None, "current_model": str(model) if model else None}
|
||||
|
||||
@app.post("/transcribe")
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(...),
|
||||
token: str = Depends(api_key_header),
|
||||
model_name: str = "turbo"
|
||||
audio_file: UploadFile = File(...),
|
||||
params: TranscribeParams = Depends(),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
# Token validation
|
||||
if token not in get_keys():
|
||||
logger.warning(f"Invalid token attempt: {token}")
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
"""Транскрибирует аудиофайл"""
|
||||
if model is None:
|
||||
raise HTTPException(status_code=500, detail="Модель не загружена")
|
||||
|
||||
logger.info(f"Processing file: {file.filename} with model: {model_name}")
|
||||
# Готовим параметры для whisper.transcribe()
|
||||
whisper_params = {}
|
||||
for field_name, field_value in params.dict(exclude_none=True, exclude={'format'}).items():
|
||||
whisper_params[field_name] = field_value
|
||||
|
||||
if file.size > int(os.getenv("MAX_UPLOAD_SIZE_MB")) * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail=f'File size exceeds ${os.getenv("MAX_UPLOAD_SIZE_MB")}MB limit')
|
||||
|
||||
metrics = TranscriptionMetrics()
|
||||
|
||||
# Save uploaded file
|
||||
temp_input_path = f"/tmp/input_{file.filename}"
|
||||
temp_output_path = f"/tmp/converted_{file.filename}.wav"
|
||||
# Формат ответа
|
||||
response_format = params.format
|
||||
|
||||
temp_file_path = None
|
||||
try:
|
||||
with open(temp_input_path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
# Сохраняем временный файл
|
||||
temp_file_path = f"/tmp/{audio_file.filename}"
|
||||
with open(temp_file_path, "wb") as temp_file:
|
||||
content = await audio_file.read()
|
||||
temp_file.write(content)
|
||||
|
||||
# Convert audio if needed
|
||||
logger.debug("Converting audio file")
|
||||
if not convert_audio(temp_input_path, temp_output_path):
|
||||
raise HTTPException(status_code=400, detail="Audio conversion failed")
|
||||
# Транскрибируем
|
||||
logger.info(f"Транскрибация файла: {audio_file.filename} с параметрами: {whisper_params}")
|
||||
result = model.transcribe(temp_file_path, **whisper_params)
|
||||
|
||||
# Get audio duration before speed up
|
||||
original_duration = get_audio_duration(temp_input_path)
|
||||
|
||||
# Transcribe
|
||||
logger.info("Starting transcription")
|
||||
if original_duration > 30:
|
||||
logger.info("Audio duration > 30 seconds, using transcribe_longform")
|
||||
cmd = [
|
||||
'ffmpeg', '-i', temp_input_path,
|
||||
'-filter:a', f'atempo={os.getenv("AUDIO_SPEEDUP", 1.25)}',
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
'-c:a', 'pcm_s16le',
|
||||
temp_output_path,
|
||||
'-y'
|
||||
]
|
||||
log = subprocess.run(cmd, check=True, capture_output=True)
|
||||
logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
|
||||
logger.info("Audio sped up for longform transcription")
|
||||
if log.stderr:
|
||||
logger.error(f"FFmpeg err log: {log.stderr.decode()}")
|
||||
logger.debug(f"FFmpeg log: {log.stdout.decode()}")
|
||||
else:
|
||||
logger.debug(f"FFmpeg log: {log.stdout.decode()}")
|
||||
|
||||
transcription_result = model.transcribe_longform(
|
||||
temp_output_path
|
||||
)
|
||||
else:
|
||||
logger.info("Audio duration <= 30 seconds, using transcribe")
|
||||
transcription_result = model.transcribe(
|
||||
temp_output_path
|
||||
)
|
||||
|
||||
full_text = ""
|
||||
for part in transcription_result:
|
||||
if part["transcription"].strip() != "":
|
||||
full_text += part["transcription"].strip() + " "
|
||||
|
||||
result = {
|
||||
"transcription": transcription_result,
|
||||
"text": full_text
|
||||
}
|
||||
|
||||
# Calculate metrics
|
||||
metrics.stop(full_text, original_duration)
|
||||
logger.info(f"Transcription metrics: {metrics.get_metrics()}")
|
||||
|
||||
# Add metrics to result
|
||||
result["metrics"] = metrics.get_metrics()
|
||||
# Удаляем временный файл
|
||||
os.unlink(temp_file_path)
|
||||
|
||||
# Возвращаем результат в нужном формате
|
||||
if response_format == 'text':
|
||||
return PlainTextResponse(content=result['text'])
|
||||
elif response_format == 'simple':
|
||||
return {"text": result['text']}
|
||||
else: # json - полный ответ по умолчанию
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
logger.error(f"Ошибка транскрибации: {e}")
|
||||
# Удаляем временный файл в случае ошибки
|
||||
if temp_file_path and os.path.exists(temp_file_path):
|
||||
os.unlink(temp_file_path)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка транскрибации: {str(e)}")
|
||||
|
||||
finally:
|
||||
# Cleanup temporary files
|
||||
if os.path.exists(temp_input_path):
|
||||
os.remove(temp_input_path)
|
||||
if os.path.exists(temp_output_path):
|
||||
os.remove(temp_output_path)
|
||||
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
get_keys()
|
||||
uvicorn.run(app, host="0.0.0.0", port=9854, log_level=os.getenv("LOG_LEVEL", "info"))
|
||||
@app.post("/keys/reload")
|
||||
async def reload_keys(api_key: str = Depends(verify_api_key)):
|
||||
"""Перезагружает ключи из файла"""
|
||||
load_api_keys()
|
||||
with keys_lock:
|
||||
return {"message": f"Перезагружено {len(api_keys)} ключей"}
|
||||
|
||||
@app.get("/keys/count")
|
||||
async def get_keys_count(api_key: str = Depends(verify_api_key)):
|
||||
"""Возвращает количество активных ключей"""
|
||||
with keys_lock:
|
||||
return {"count": len(api_keys)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", "9854"))
|
||||
log_level = os.getenv("LOG_LEVEL", "info")
|
||||
|
||||
uvicorn.run(
|
||||
"app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level=log_level,
|
||||
reload=False
|
||||
)
|
||||
|
||||
59
docker-entrypoint.sh
Normal file
59
docker-entrypoint.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker entrypoint script for ASR service
|
||||
# Generates API keys if needed and starts the server
|
||||
|
||||
set -e
|
||||
|
||||
# Function to generate a secure API key
|
||||
generate_api_key() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Set default values for environment variables
|
||||
export HOST=${HOST:-"0.0.0.0"}
|
||||
export PORT=${PORT:-9854}
|
||||
export DEFAULT_MODEL=${DEFAULT_MODEL:-"turbo"}
|
||||
export MODEL_DOWNLOAD_ROOT=${MODEL_DOWNLOAD_ROOT:-"/app/models"}
|
||||
export KEYS_FILE=${KEYS_FILE:-"/app/data/keys.txt"}
|
||||
export LOG_LEVEL=${LOG_LEVEL:-"info"}
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p "${MODEL_DOWNLOAD_ROOT}"
|
||||
mkdir -p "$(dirname "${KEYS_FILE}")"
|
||||
|
||||
# Check if keys file exists, create with generated key if not
|
||||
if [ ! -f "${KEYS_FILE}" ]; then
|
||||
echo "Creating default keys file..."
|
||||
|
||||
# Try to generate a secure key with openssl
|
||||
GENERATED_KEY=$(generate_api_key)
|
||||
|
||||
if [ -n "${GENERATED_KEY}" ]; then
|
||||
echo "${GENERATED_KEY}" > "${KEYS_FILE}"
|
||||
echo "Generated secure API key using openssl: ${GENERATED_KEY}"
|
||||
echo "Created keys file at: ${KEYS_FILE}"
|
||||
else
|
||||
echo "WARNING: openssl not found! Cannot generate secure API key."
|
||||
echo "Please manually add API keys to ${KEYS_FILE}"
|
||||
echo "Each key should be 64 hex characters (32 bytes) on a separate line."
|
||||
echo ""
|
||||
echo "Creating empty keys file - you must add keys manually before starting the server."
|
||||
touch "${KEYS_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting Simple ASR Server in Docker..."
|
||||
echo "Host: ${HOST}"
|
||||
echo "Port: ${PORT}"
|
||||
echo "Default Model: ${DEFAULT_MODEL}"
|
||||
echo "Model Download Root: ${MODEL_DOWNLOAD_ROOT}"
|
||||
echo "Keys File: ${KEYS_FILE}"
|
||||
echo "Log Level: ${LOG_LEVEL}"
|
||||
|
||||
# Start the server
|
||||
exec python app.py
|
||||
@@ -3,5 +3,4 @@ uvicorn[standard]
|
||||
python-multipart
|
||||
openai-whisper
|
||||
python-dotenv
|
||||
|
||||
|
||||
pydantic
|
||||
|
||||
@@ -9,6 +9,15 @@ set -e
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${SCRIPT_DIR}"
|
||||
|
||||
# Function to generate a secure API key
|
||||
generate_api_key() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 32
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Load environment variables from .env file if it exists
|
||||
if [ -f "${APP_DIR}/.env" ]; then
|
||||
echo "Loading environment variables from ${APP_DIR}/.env"
|
||||
@@ -25,13 +34,35 @@ export HOST=${HOST:-"0.0.0.0"}
|
||||
export PORT=${PORT:-9854}
|
||||
export DEFAULT_MODEL=${DEFAULT_MODEL:-"turbo"}
|
||||
export MODEL_DOWNLOAD_ROOT=${MODEL_DOWNLOAD_ROOT:-"${APP_DIR}/models"}
|
||||
export KEYS_FILE=${KEYS_FILE:-"${APP_DIR}/keys.txt"}
|
||||
export LOG_LEVEL=${LOG_LEVEL:-"INFO"}
|
||||
export KEYS_FILE=${KEYS_FILE:-"${APP_DIR}/data/keys.txt"}
|
||||
export LOG_LEVEL=${LOG_LEVEL:-"info"}
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p "${MODEL_DOWNLOAD_ROOT}"
|
||||
mkdir -p "$(dirname "${KEYS_FILE}")"
|
||||
|
||||
# Check if keys file exists, create with generated key if not
|
||||
if [ ! -f "${KEYS_FILE}" ]; then
|
||||
echo "Creating default keys file..."
|
||||
|
||||
# Try to generate a secure key with openssl
|
||||
GENERATED_KEY=$(generate_api_key)
|
||||
|
||||
if [ -n "${GENERATED_KEY}" ]; then
|
||||
echo "${GENERATED_KEY}" > "${KEYS_FILE}"
|
||||
echo "Generated secure API key using openssl: ${GENERATED_KEY}"
|
||||
echo "Created keys file at: ${KEYS_FILE}"
|
||||
else
|
||||
echo "WARNING: openssl not found! Cannot generate secure API key."
|
||||
echo "Please manually add API keys to ${KEYS_FILE}"
|
||||
echo "Each key should be 64 hex characters (32 bytes) on a separate line."
|
||||
echo "Example key format: 0000000000000000000000000000000000000000000000000000000000000000"
|
||||
echo ""
|
||||
echo "Creating empty keys file - you must add keys manually before starting the server."
|
||||
touch "${KEYS_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if virtual environment exists, create if not
|
||||
VENV_DIR="${APP_DIR}/venv"
|
||||
if [ ! -d "${VENV_DIR}" ]; then
|
||||
@@ -59,6 +90,5 @@ echo "Model Download Root: ${MODEL_DOWNLOAD_ROOT}"
|
||||
echo "Keys File: ${KEYS_FILE}"
|
||||
echo "Log Level: ${LOG_LEVEL}"
|
||||
|
||||
# Start the application
|
||||
exec python3 app.py
|
||||
|
||||
# Start the server
|
||||
exec python app.py
|
||||
|
||||
Reference in New Issue
Block a user