diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/Dockerfile b/Dockerfile index 9b71450..3504e99 100644 --- a/Dockerfile +++ b/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"] diff --git a/README.MD b/README.MD index e1e5960..8046386 100644 --- a/README.MD +++ b/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 секунд diff --git a/app.py b/app.py index 43dd218..3a2c3d1 100644 --- a/app.py +++ b/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] - 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 - """ +def load_api_keys(): + """Загружает API ключи из файла""" + global api_keys 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 - ) + 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: - 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) + # Удаляем временный файл + os.unlink(temp_file_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() - - return result + # Возвращаем результат в нужном формате + 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 + ) diff --git a/simple-asr-server.service b/asr.service similarity index 100% rename from simple-asr-server.service rename to asr.service diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..4dff360 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 diff --git a/requirements.txt b/requirements.txt index c3f060d..9bc17d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ uvicorn[standard] python-multipart openai-whisper python-dotenv - - +pydantic diff --git a/start_server.sh b/start_server.sh index 2ac7bf9..2884ff6 100755 --- a/start_server.sh +++ b/start_server.sh @@ -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