Refactor project structure and add new features
This commit is contained in:
+90
-54
@@ -1,15 +1,33 @@
|
||||
import json
|
||||
"""
|
||||
Главный модуль приложения, содержащий класс WhisperServiceAPI для инициализации
|
||||
и запуска сервиса распознавания речи.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from flask import Flask
|
||||
from typing import Dict
|
||||
from flask import Flask
|
||||
import waitress
|
||||
|
||||
from .core.transcriber import WhisperTranscriber
|
||||
from .core.config import load_config
|
||||
from .api.routes import Routes
|
||||
from .infrastructure.validation.validators import FileValidator
|
||||
from .infrastructure.storage.file_manager import temp_file_manager
|
||||
from .infrastructure.logging.config import setup_logging
|
||||
from .infrastructure.logging.request_logger import RequestLogger
|
||||
|
||||
# Импорт классов и функций из других модулей
|
||||
from .transcriber import WhisperTranscriber
|
||||
from .routes import Routes
|
||||
from .utils import logger
|
||||
|
||||
class WhisperServiceAPI:
|
||||
"""Класс для API сервиса распознавания речи."""
|
||||
"""
|
||||
Класс для API сервиса распознавания речи.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
port (int): Порт для сервиса.
|
||||
transcriber (WhisperTranscriber): Экземпляр транскрайбера.
|
||||
app (Flask): Flask-приложение.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
"""
|
||||
@@ -19,50 +37,68 @@ class WhisperServiceAPI:
|
||||
config_path: Путь к конфигурационному файлу.
|
||||
"""
|
||||
# Загрузка конфигурации
|
||||
self.config = self._load_config(config_path)
|
||||
|
||||
# Порт для сервиса
|
||||
self.port = self.config["service_port"]
|
||||
|
||||
# Создание экземпляра транскрайбера
|
||||
self.transcriber = WhisperTranscriber(self.config)
|
||||
|
||||
# Создание Flask-приложения
|
||||
self.app = Flask("whisper-service")
|
||||
|
||||
# Регистрация маршрутов
|
||||
Routes(self.app, self.transcriber, self.config)
|
||||
|
||||
logger.info(f"API сервис инициализирован, порт: {self.port}")
|
||||
|
||||
def _load_config(self, config_path: str) -> Dict:
|
||||
"""
|
||||
Загрузка конфигурации из JSON-файла.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации.
|
||||
|
||||
Returns:
|
||||
Словарь с параметрами конфигурации.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
|
||||
"""
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
return config
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"Файл конфигурации не найден: {e}")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка при загрузке конфигурации: {e}")
|
||||
raise
|
||||
|
||||
def run(self):
|
||||
"""Запуск сервиса."""
|
||||
logger.info(f"Запуск сервиса на порту {self.port}")
|
||||
self.config = load_config(config_path)
|
||||
|
||||
# Использовать waitress для production-ready сервера
|
||||
waitress.serve(self.app, host='0.0.0.0', port=self.port)
|
||||
# Установка уровня логирования
|
||||
log_level = getattr(logging, self.config.get('log_level', 'INFO').upper())
|
||||
log_file = self.config.get('log_file')
|
||||
setup_logging(log_level=log_level, log_file=log_file)
|
||||
|
||||
# Получаем логгер для этого модуля
|
||||
self.logger = logging.getLogger('app')
|
||||
self.logger.info("Инициализация WhisperServiceAPI")
|
||||
|
||||
# Инициализация Flask приложения
|
||||
self.app = Flask(__name__)
|
||||
self.port = self.config.get('port', 5000)
|
||||
|
||||
# Инициализация компонентов
|
||||
self.transcriber = WhisperTranscriber(self.config)
|
||||
self.file_validator = FileValidator(self.config)
|
||||
|
||||
# Настройка логирования запросов
|
||||
request_logger_config = self.config.get('request_logger', {})
|
||||
request_logger = RequestLogger(self.app, request_logger_config)
|
||||
|
||||
# Регистрация маршрутов
|
||||
routes = Routes(self.app, self.transcriber, self.config, self.file_validator)
|
||||
|
||||
# Регистрация обработчиков очистки
|
||||
self._register_cleanup_handlers()
|
||||
|
||||
self.logger.info("WhisperServiceAPI успешно инициализирован")
|
||||
|
||||
def _register_cleanup_handlers(self) -> None:
|
||||
"""
|
||||
Регистрация обработчиков для очистки ресурсов при завершении приложения.
|
||||
"""
|
||||
@self.app.teardown_appcontext
|
||||
def cleanup(error):
|
||||
"""
|
||||
Очистка временных файлов при завершении контекста запроса.
|
||||
|
||||
Args:
|
||||
error: Ошибка, если она произошла.
|
||||
"""
|
||||
# Выполнение очистки временных файлов, если необходимо
|
||||
pass
|
||||
|
||||
def run(self, host: str = '0.0.0.0', debug: bool = False) -> None:
|
||||
"""
|
||||
Запуск Flask приложения.
|
||||
|
||||
Args:
|
||||
host: Хост для запуска приложения.
|
||||
debug: Флаг отладочного режима.
|
||||
"""
|
||||
self.logger.info(f"Запуск сервиса на {host}:{self.port}")
|
||||
self.app.run(host=host, port=self.port, debug=debug)
|
||||
|
||||
def create_app(self) -> Flask:
|
||||
"""
|
||||
Создание и настройка Flask приложения (для использования с WSGI серверами).
|
||||
|
||||
Returns:
|
||||
Настроенное Flask приложение.
|
||||
"""
|
||||
return self.app
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Модуль middleware.py содержит различные middleware для Flask приложения.
|
||||
"""
|
||||
|
||||
# Request logging middleware is already implemented in infrastructure/logging/request_logger.py
|
||||
# This file can be used for additional middleware if needed in the future
|
||||
@@ -6,20 +6,35 @@
|
||||
import os
|
||||
from flask import request, jsonify
|
||||
from typing import Dict
|
||||
import logging
|
||||
|
||||
from .transcriber_service import TranscriptionService
|
||||
from .audio_sources import (
|
||||
from ..core.transcription_service import TranscriptionService
|
||||
from ..audio.sources import (
|
||||
UploadedFileSource,
|
||||
URLSource,
|
||||
Base64Source,
|
||||
LocalFileSource
|
||||
)
|
||||
from ..infrastructure.validation.validators import ValidationError
|
||||
from ..infrastructure.async_tasks.manager import transcribe_audio_async, task_manager
|
||||
from ..infrastructure.storage.cache import model_cache
|
||||
from ..shared.decorators import log_invalid_file_request
|
||||
|
||||
logger = logging.getLogger('app.routes')
|
||||
|
||||
|
||||
class Routes:
|
||||
"""Класс для регистрации всех эндпоинтов API."""
|
||||
"""
|
||||
Класс для регистрации всех эндпоинтов API.
|
||||
|
||||
Attributes:
|
||||
app (Flask): Flask-приложение.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
transcription_service (TranscriptionService): Сервис транскрибации.
|
||||
file_validator (FileValidator): Валидатор файлов.
|
||||
"""
|
||||
|
||||
def __init__(self, app, transcriber, config: Dict):
|
||||
def __init__(self, app, transcriber, config: Dict, file_validator):
|
||||
"""
|
||||
Инициализация маршрутов.
|
||||
|
||||
@@ -27,16 +42,24 @@ class Routes:
|
||||
app: Flask-приложение.
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
file_validator: Валидатор файлов.
|
||||
"""
|
||||
self.app = app
|
||||
self.config = config
|
||||
self.transcription_service = TranscriptionService(transcriber, config)
|
||||
self.file_validator = file_validator
|
||||
|
||||
# Регистрация маршрутов
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""Регистрация всех эндпоинтов."""
|
||||
def _register_routes(self) -> None:
|
||||
"""
|
||||
Регистрация всех эндпоинтов.
|
||||
"""
|
||||
@self.app.route('/', methods=['GET'])
|
||||
def index():
|
||||
"""Корень. Отдаёт HTML клиент."""
|
||||
return self.app.send_static_file('index.html')
|
||||
|
||||
@self.app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
@@ -60,7 +83,21 @@ class Routes:
|
||||
return jsonify({"error": "No file_path provided"}), 400
|
||||
|
||||
file_path = data["file_path"]
|
||||
source = LocalFileSource(file_path, self.config.get("max_file_size", 100))
|
||||
|
||||
# Валидация пути к файлу
|
||||
try:
|
||||
validated_path = self.file_validator.validate_local_file_path(
|
||||
file_path,
|
||||
allowed_directories=self.config.get("allowed_directories", [])
|
||||
)
|
||||
except ValidationError as e:
|
||||
# Логирование обращения к API с невалидным путем к файлу
|
||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
||||
logger.warning(f"Обращение к эндпоинту /local/transcriptions с невалидным путем к файлу '{file_path}' "
|
||||
f"от клиента {client_ip}. Ошибка: {str(e)}")
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
source = LocalFileSource(validated_path, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, data)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@@ -95,14 +132,20 @@ class Routes:
|
||||
"details": f"Model '{model_id}' does not exist"
|
||||
}), 404
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
|
||||
def openai_transcribe_endpoint():
|
||||
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
|
||||
source = UploadedFileSource(request.files, self.config.get("max_file_size", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, request.form)
|
||||
def _handle_transcription_request():
|
||||
"""Общая функция для обработки запросов транскрибации."""
|
||||
source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, request.form, self.file_validator)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def openai_transcribe_endpoint():
|
||||
"""Эндпоинт для транскрибации аудиофайла (multipart-форма)."""
|
||||
return _handle_transcription_request()
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_from_url():
|
||||
"""Эндпоинт для транскрибации аудиофайла по URL."""
|
||||
data = request.json
|
||||
@@ -117,11 +160,12 @@ class Routes:
|
||||
# Извлекаем параметры транскрибации, если они есть
|
||||
params = {k: v for k, v in data.items() if k != "url"}
|
||||
|
||||
source = URLSource(url, self.config.get("max_file_size", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params)
|
||||
source = URLSource(url, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_from_base64():
|
||||
"""Эндпоинт для транскрибации аудио, закодированного в base64."""
|
||||
data = request.json
|
||||
@@ -136,13 +180,63 @@ class Routes:
|
||||
# Извлекаем параметры транскрибации, если они есть
|
||||
params = {k: v for k, v in data.items() if k != "file"}
|
||||
|
||||
source = Base64Source(base64_data, self.config.get("max_file_size", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params)
|
||||
source = Base64Source(base64_data, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, params, self.file_validator)
|
||||
return jsonify(response), status_code
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/multipart', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_multipart():
|
||||
"""Эндпоинт для транскрибации аудиофайла, загруженного через форму."""
|
||||
source = UploadedFileSource(request.files, self.config.get("max_file_size", 100))
|
||||
response, status_code = self.transcription_service.transcribe_from_source(source, request.form)
|
||||
return jsonify(response), status_code
|
||||
return _handle_transcription_request()
|
||||
|
||||
@self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
|
||||
@log_invalid_file_request
|
||||
def transcribe_async():
|
||||
"""Эндпоинт для асинхронной транскрибации аудиофайла."""
|
||||
source = UploadedFileSource(request.files, self.config.get("file_validation", {}).get("max_file_size_mb", 100))
|
||||
|
||||
# Получаем файл
|
||||
file, filename, error = source.get_audio_file()
|
||||
|
||||
if error:
|
||||
return jsonify({"error": error}), 400
|
||||
|
||||
if not file:
|
||||
return jsonify({"error": "Failed to get audio file"}), 400
|
||||
|
||||
# Валидация файла
|
||||
try:
|
||||
self.file_validator.validate_file(file, filename)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
with temp_file_manager.temp_file() as temp_path:
|
||||
file.save(temp_path)
|
||||
|
||||
# Запускаем асинхронную транскрибацию
|
||||
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
|
||||
|
||||
return jsonify({"task_id": task_id}), 202
|
||||
|
||||
@self.app.route('/v1/tasks/<task_id>', methods=['GET'])
|
||||
def get_task_status(task_id):
|
||||
"""Эндпоинт для получения статуса асинхронной задачи."""
|
||||
task_info = task_manager.get_task_status(task_id)
|
||||
|
||||
if not task_info:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
|
||||
response = {
|
||||
"task_id": task_id,
|
||||
"status": task_info["status"]
|
||||
}
|
||||
|
||||
if task_info["status"] == "completed":
|
||||
response["result"] = task_info["result"]
|
||||
elif task_info["status"] == "failed":
|
||||
response["error"] = task_info["error"]
|
||||
|
||||
return jsonify(response)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Модуль audio_processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
|
||||
Модуль processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
|
||||
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
|
||||
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
|
||||
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
|
||||
@@ -7,15 +7,25 @@
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Dict, Tuple
|
||||
import logging
|
||||
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
from ..shared.context_managers import open_file
|
||||
|
||||
logger = logging.getLogger('app.audio_processor')
|
||||
|
||||
# Импорт классов и функций из других модулей
|
||||
from .utils import logger
|
||||
|
||||
class AudioProcessor:
|
||||
"""Класс для предобработки аудиофайлов перед распознаванием."""
|
||||
"""
|
||||
Класс для предобработки аудиофайлов перед распознаванием.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
norm_level (str): Уровень нормализации аудио.
|
||||
compand_params (str): Параметры компрессора аудио.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
@@ -27,6 +37,7 @@ class AudioProcessor:
|
||||
self.config = config
|
||||
self.norm_level = config.get("norm_level", "-0.5")
|
||||
self.compand_params = config.get("compand_params", "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2")
|
||||
self.audio_speed_factor = config.get("audio_speed_factor", 1.25)
|
||||
|
||||
def convert_to_wav(self, input_path: str) -> str:
|
||||
"""
|
||||
@@ -37,8 +48,10 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Путь к сконвертированному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при конвертации.
|
||||
"""
|
||||
|
||||
audio_rate = self.config["audio_rate"]
|
||||
|
||||
# Проверка расширения файла
|
||||
@@ -54,8 +67,7 @@ class AudioProcessor:
|
||||
# Продолжаем конвертацию, чтобы быть уверенными в формате
|
||||
|
||||
# Создаем временный файл для WAV
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.wav")
|
||||
output_path, _ = temp_file_manager.create_temp_file(".wav")
|
||||
|
||||
# Команда для конвертации
|
||||
cmd = [
|
||||
@@ -87,10 +99,12 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Путь к нормализованному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
|
||||
"""
|
||||
# Создаем временный файл для нормализованного аудио
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_normalized.wav")
|
||||
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
|
||||
|
||||
# Команда для нормализации аудио с помощью sox
|
||||
cmd = [
|
||||
@@ -111,6 +125,47 @@ class AudioProcessor:
|
||||
logger.error(f"Ошибка при нормализации аудио: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def speed_up_audio(self, input_path: str) -> str:
|
||||
"""
|
||||
Ускоряет воспроизведение аудиофайла с использованием FFmpeg.
|
||||
|
||||
Args:
|
||||
input_path: Путь к WAV-файлу.
|
||||
|
||||
Returns:
|
||||
Путь к ускоренному WAV-файлу.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при ускорении.
|
||||
"""
|
||||
# Если ускорение не требуется (коэффициент = 1.0), возвращаем исходный файл
|
||||
if float(self.audio_speed_factor) == 1.0:
|
||||
logger.info(f"Ускорение не требуется (коэффициент = {self.audio_speed_factor})")
|
||||
return input_path
|
||||
|
||||
# Создаем временный файл для ускоренного аудио
|
||||
output_path, _ = temp_file_manager.create_temp_file("_speedup.wav")
|
||||
|
||||
# Команда для ускорения аудио с помощью FFmpeg
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel", "warning",
|
||||
"-i", input_path,
|
||||
"-filter:a", f"atempo={self.audio_speed_factor}",
|
||||
output_path
|
||||
]
|
||||
|
||||
logger.info(f"Ускорение аудио в {self.audio_speed_factor}x: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
logger.info(f"Аудио ускорено: {output_path}")
|
||||
return output_path
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при ускорении аудио: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def add_silence(self, input_path: str) -> str:
|
||||
"""
|
||||
Добавляет тишину в начало аудиофайла.
|
||||
@@ -120,10 +175,12 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Путь к аудиофайлу с добавленной тишиной.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
|
||||
"""
|
||||
# Создаем временный файл
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_silence.wav")
|
||||
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
|
||||
|
||||
# Команда для добавления тишины в начало файла
|
||||
cmd = [
|
||||
@@ -143,27 +200,6 @@ class AudioProcessor:
|
||||
logger.error(f"Ошибка при добавлении тишины: {e.stderr.decode()}")
|
||||
raise
|
||||
|
||||
def cleanup_temp_files(self, file_paths: list):
|
||||
"""
|
||||
Удаление временных файлов и директорий.
|
||||
|
||||
Args:
|
||||
file_paths: Список путей к временным файлам.
|
||||
"""
|
||||
for path in file_paths:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.debug(f"Удален временный файл: {path}")
|
||||
|
||||
# Попытка удалить директорию, если она пуста
|
||||
temp_dir = os.path.dirname(path)
|
||||
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
logger.debug(f"Удалена временная директория: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
|
||||
|
||||
def process_audio(self, input_path: str) -> Tuple[str, list]:
|
||||
"""
|
||||
Полная обработка аудиофайла: конвертация, нормализация и добавление тишины.
|
||||
@@ -173,6 +209,9 @@ class AudioProcessor:
|
||||
|
||||
Returns:
|
||||
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
|
||||
|
||||
Raises:
|
||||
Exception: Если произошла ошибка при обработке аудио.
|
||||
"""
|
||||
temp_files = []
|
||||
|
||||
@@ -186,13 +225,18 @@ class AudioProcessor:
|
||||
normalized_path = self.normalize_audio(wav_path)
|
||||
temp_files.append(normalized_path)
|
||||
|
||||
# УСКОРЕНИЕ ЗВУКА (НОВЫЙ ШАГ)
|
||||
speedup_path = self.speed_up_audio(normalized_path)
|
||||
if speedup_path != normalized_path: # Если был создан временный файл
|
||||
temp_files.append(speedup_path)
|
||||
|
||||
# Добавление тишины
|
||||
silence_path = self.add_silence(normalized_path)
|
||||
silence_path = self.add_silence(speedup_path)
|
||||
temp_files.append(silence_path)
|
||||
|
||||
return silence_path, temp_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
|
||||
self.cleanup_temp_files(temp_files)
|
||||
raise
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
raise
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Модуль audio_sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
||||
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
|
||||
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
|
||||
"""
|
||||
|
||||
@@ -10,8 +10,9 @@ import base64
|
||||
import requests
|
||||
import abc
|
||||
from typing import Dict, Tuple, Optional, BinaryIO
|
||||
import logging
|
||||
|
||||
from .utils import logger
|
||||
logger = logging.getLogger('app.audio_sources')
|
||||
|
||||
class AudioSource(abc.ABC):
|
||||
"""Абстрактный класс для различных источников аудиофайлов.
|
||||
@@ -308,4 +309,4 @@ class LocalFileSource(AudioSource):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}")
|
||||
return None, None, f"Error opening local file: {str(e)}"
|
||||
return None, None, f"Error opening local file: {str(e)}"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Модуль utils.py содержит утилитарные функции для работы с аудио.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import wave
|
||||
import numpy as np
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
logger = logging.getLogger('app.audio_utils')
|
||||
|
||||
|
||||
class AudioUtils:
|
||||
"""Утилитарный класс для работы с аудио."""
|
||||
|
||||
@staticmethod
|
||||
def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Загрузка аудиофайла с использованием встроенной библиотеки wave.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
sr: Целевая частота дискретизации.
|
||||
|
||||
Returns:
|
||||
Кортеж (массив numpy, частота дискретизации).
|
||||
|
||||
Raises:
|
||||
Exception: Если не удалось загрузить аудиофайл.
|
||||
"""
|
||||
try:
|
||||
# Открываем WAV файл
|
||||
with wave.open(file_path, 'rb') as wav_file:
|
||||
# Проверяем, что это моно-аудио
|
||||
if wav_file.getnchannels() != 1:
|
||||
logger.warning(f"Файл {file_path} не моно-аудио, конвертируем в моно")
|
||||
|
||||
# Читаем аудиоданные
|
||||
frames = wav_file.readframes(-1)
|
||||
# Конвертируем 16-битные целые числа в float32 в диапазоне [-1.0, 1.0]
|
||||
# 32768.0 - это 2^15, максимальное значение для 16-битного знакового целого
|
||||
audio_array = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
# Получаем частоту дискретизации
|
||||
sampling_rate = wav_file.getframerate()
|
||||
|
||||
# Если частота дискретизации не совпадает с целевой, выполняем ресемплинг
|
||||
if sampling_rate != sr:
|
||||
from scipy.signal import resample
|
||||
num_samples = int(len(audio_array) * sr / sampling_rate)
|
||||
audio_array = resample(audio_array, num_samples)
|
||||
sampling_rate = sr
|
||||
|
||||
return audio_array, sampling_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_audio_duration(file_path: str) -> float:
|
||||
"""
|
||||
Определяет длительность аудиофайла с использованием ffprobe.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Длительность в секундах.
|
||||
"""
|
||||
try:
|
||||
# Проверяем, что файл существует
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"Файл не существует: {file_path}")
|
||||
raise Exception(f"Файл не существует: {file_path}")
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
file_path
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10 # Ограничение по времени выполнения
|
||||
)
|
||||
|
||||
duration = float(result.stdout.strip())
|
||||
return duration
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"Таймаут при определении длительности файла {file_path}")
|
||||
raise Exception(f"Таймаут при определении длительности файла {file_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}")
|
||||
raise Exception(f"Ошибка при выполнении ffprobe для файла {file_path}: {e.stderr}")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||
raise Exception(f"Ошибка при преобразовании длительности для файла {file_path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}")
|
||||
raise Exception(f"Неожиданная ошибка при определении длительности файла {file_path}: {e}")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Модуль config.py содержит функции для управления конфигурацией приложения.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger('app.config')
|
||||
|
||||
|
||||
def load_config(config_path: str) -> Dict:
|
||||
"""
|
||||
Загружает конфигурацию из JSON-файла.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации.
|
||||
|
||||
Returns:
|
||||
Словарь с параметрами конфигурации.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
|
||||
"""
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
logger.info(f"Конфигурация успешно загружена из {config_path}")
|
||||
return config
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"Файл конфигурации не найден: {e}")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка при загрузке конфигурации: {e}")
|
||||
raise
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Модуль transcribe.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
|
||||
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
|
||||
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
|
||||
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
|
||||
@@ -7,9 +7,10 @@ OpenAI для транскрибации аудиофайлов в текст.
|
||||
"""
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from typing import Dict, Tuple, Union
|
||||
import logging
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import torch
|
||||
from transformers import (
|
||||
@@ -18,12 +19,34 @@ from transformers import (
|
||||
pipeline,
|
||||
)
|
||||
|
||||
from .audio_processor import AudioProcessor
|
||||
from .utils import logger
|
||||
from ..audio.processor import AudioProcessor
|
||||
from ..audio.utils import AudioUtils
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
|
||||
logger = logging.getLogger('app.transcriber')
|
||||
|
||||
|
||||
class WhisperTranscriber:
|
||||
"""Класс для распознавания речи с помощью модели Whisper."""
|
||||
|
||||
"""
|
||||
Класс для распознавания речи с помощью модели Whisper.
|
||||
|
||||
Attributes:
|
||||
config (Dict): Словарь с параметрами конфигурации.
|
||||
model_path (str): Путь к модели Whisper.
|
||||
language (str): Язык распознавания.
|
||||
chunk_length_s (int): Длина аудиочанка в секундах.
|
||||
batch_size (int): Размер пакета для обработки.
|
||||
max_new_tokens (int): Максимальное количество новых токенов для генерации.
|
||||
return_timestamps (bool): Флаг возврата временных меток.
|
||||
temperature (float): Параметр температуры для генерации.
|
||||
torch_dtype (torch.dtype): Оптимальный тип данных для тензоров.
|
||||
audio_processor (AudioProcessor): Объект для обработки аудио.
|
||||
device (torch.device): Устройство для вычислений.
|
||||
model (WhisperForConditionalGeneration): Загруженная модель Whisper.
|
||||
processor (WhisperProcessor): Процессор для модели Whisper.
|
||||
asr_pipeline (pipeline): Пайплайн для автоматического распознавания речи.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация транскрайбера.
|
||||
@@ -53,7 +76,12 @@ class WhisperTranscriber:
|
||||
self._load_model()
|
||||
|
||||
def _get_device(self) -> torch.device:
|
||||
"""Определение доступного устройства для вычислений."""
|
||||
"""
|
||||
Определение доступного устройства для вычислений.
|
||||
|
||||
Returns:
|
||||
Объект устройства PyTorch.
|
||||
"""
|
||||
if torch.cuda.is_available():
|
||||
# Проверяем, доступна ли GPU с индексом 1
|
||||
if torch.cuda.device_count() > 1:
|
||||
@@ -71,8 +99,13 @@ class WhisperTranscriber:
|
||||
logger.info("Используется CPU для вычислений")
|
||||
return torch.device("cpu")
|
||||
|
||||
def _load_model(self):
|
||||
"""Загрузка модели и процессора."""
|
||||
def _load_model(self) -> None:
|
||||
"""
|
||||
Загрузка модели и процессора.
|
||||
|
||||
Raises:
|
||||
Exception: Если не удалось загрузить модель.
|
||||
"""
|
||||
logger.info(f"Загрузка модели из {self.model_path}")
|
||||
|
||||
try:
|
||||
@@ -119,23 +152,6 @@ class WhisperTranscriber:
|
||||
|
||||
logger.info("Модель успешно загружена и готова к использованию")
|
||||
|
||||
def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]:
|
||||
"""
|
||||
Загрузка аудиофайла с использованием librosa.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Tuple с массивом numpy и частотой дискретизации.
|
||||
"""
|
||||
try:
|
||||
audio_array, sampling_rate = librosa.load(file_path, sr=16000)
|
||||
return audio_array, sampling_rate
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
|
||||
raise
|
||||
|
||||
def transcribe(self, audio_path: str) -> Union[str, Dict]:
|
||||
"""
|
||||
Транскрибация аудиофайла.
|
||||
@@ -149,61 +165,72 @@ class WhisperTranscriber:
|
||||
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
|
||||
"""
|
||||
logger.info(f"Начало транскрибации файла: {audio_path}")
|
||||
|
||||
# Загрузка аудио в формате numpy array
|
||||
audio_array, sampling_rate = self._load_audio(audio_path)
|
||||
|
||||
# Транскрибация с корректным форматом данных
|
||||
result = self.asr_pipeline(
|
||||
{"raw": audio_array, "sampling_rate": sampling_rate},
|
||||
generate_kwargs={"language": self.language, "max_new_tokens": self.max_new_tokens, "temperature": self.temperature},
|
||||
return_timestamps=self.return_timestamps
|
||||
)
|
||||
|
||||
# Если временные метки не запрошены, возвращаем только текст
|
||||
if not self.return_timestamps:
|
||||
transcribed_text = result.get("text", "")
|
||||
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
|
||||
return transcribed_text
|
||||
|
||||
# Если временные метки запрошены, обрабатываем и форматируем результат
|
||||
segments = []
|
||||
full_text = result.get("text", "")
|
||||
|
||||
if "chunks" in result:
|
||||
# Для новых версий модели Whisper
|
||||
for chunk in result["chunks"]:
|
||||
start_time = chunk.get("timestamp", [0, 0])[0]
|
||||
end_time = chunk.get("timestamp", [0, 0])[1]
|
||||
text = chunk.get("text", "").strip()
|
||||
|
||||
segments.append({
|
||||
"start_time_ms": int(start_time * 1000),
|
||||
"end_time_ms": int(end_time * 1000),
|
||||
"text": text
|
||||
})
|
||||
elif hasattr(result, "get") and "segments" in result:
|
||||
# Для старых версий модели Whisper
|
||||
for segment in result["segments"]:
|
||||
start_time = segment.get("start", 0)
|
||||
end_time = segment.get("end", 0)
|
||||
text = segment.get("text", "").strip()
|
||||
|
||||
segments.append({
|
||||
"start_time_ms": int(start_time * 1000),
|
||||
"end_time_ms": int(end_time * 1000),
|
||||
"text": text
|
||||
})
|
||||
else:
|
||||
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
|
||||
|
||||
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
|
||||
|
||||
# Возвращаем словарь с сегментами и полным текстом
|
||||
return {
|
||||
"segments": segments,
|
||||
"text": full_text
|
||||
}
|
||||
try:
|
||||
# Загрузка аудио в формате numpy array
|
||||
audio_array, sampling_rate = AudioUtils.load_audio(audio_path, sr=16000)
|
||||
|
||||
# Транскрибация с корректным форматом данных
|
||||
result = self.asr_pipeline(
|
||||
{"raw": audio_array, "sampling_rate": sampling_rate},
|
||||
generate_kwargs={
|
||||
"language": self.language,
|
||||
"max_new_tokens": self.max_new_tokens,
|
||||
"temperature": self.temperature
|
||||
},
|
||||
return_timestamps=self.return_timestamps
|
||||
)
|
||||
|
||||
# Если временные метки не запрошены, возвращаем только текст
|
||||
if not self.return_timestamps:
|
||||
transcribed_text = result.get("text", "")
|
||||
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
|
||||
return transcribed_text
|
||||
|
||||
# Если временные метки запрошены, обрабатываем и форматируем результат
|
||||
segments = []
|
||||
full_text = result.get("text", "")
|
||||
|
||||
if "chunks" in result:
|
||||
# Для новых версий модели Whisper
|
||||
for chunk in result["chunks"]:
|
||||
start_time = chunk.get("timestamp", [0, 0])[0]
|
||||
end_time = chunk.get("timestamp", [0, 0])[1]
|
||||
text = chunk.get("text", "").strip()
|
||||
|
||||
segments.append({
|
||||
"start_time_ms": int(start_time * 1000),
|
||||
"end_time_ms": int(end_time * 1000),
|
||||
"text": text
|
||||
})
|
||||
elif hasattr(result, "get") and "segments" in result:
|
||||
# Для старых версий модели Whisper
|
||||
for segment in result["segments"]:
|
||||
start_time = segment.get("start", 0)
|
||||
end_time = segment.get("end", 0)
|
||||
text = segment.get("text", "").strip()
|
||||
|
||||
segments.append({
|
||||
"start_time_ms": int(start_time * 1000),
|
||||
"end_time_ms": int(end_time * 1000),
|
||||
"text": text
|
||||
})
|
||||
else:
|
||||
logger.warning("Временные метки запрошены, но не найдены в результате транскрибации")
|
||||
|
||||
logger.info(f"Транскрибация с временными метками завершена: получено {len(segments)} сегментов")
|
||||
|
||||
# Возвращаем словарь с сегментами и полным текстом
|
||||
return {
|
||||
"segments": segments,
|
||||
"text": full_text
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в процессе транскрибации аудиофайла '{audio_path}': {str(e)}")
|
||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def process_file(self, input_path: str) -> Union[str, Dict]:
|
||||
"""
|
||||
@@ -219,21 +246,28 @@ class WhisperTranscriber:
|
||||
"""
|
||||
start_time = time.time()
|
||||
logger.info(f"Начало обработки файла: {input_path}")
|
||||
|
||||
|
||||
temp_files = []
|
||||
|
||||
|
||||
try:
|
||||
# Обработка аудио (конвертация, нормализация, добавление тишины)
|
||||
processed_path, temp_files = self.audio_processor.process_audio(input_path)
|
||||
|
||||
|
||||
# Транскрибация
|
||||
result = self.transcribe(processed_path)
|
||||
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.error(f"Ошибка при обработке файла '{input_path}' через {elapsed_time:.2f} секунд: {str(e)}")
|
||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
# Очистка временных файлов
|
||||
self.audio_processor.cleanup_temp_files(temp_files)
|
||||
temp_file_manager.cleanup_temp_files(temp_files)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Модуль transcription_service.py содержит класс TranscriptionService,
|
||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from typing import Dict, Tuple
|
||||
import logging
|
||||
|
||||
from ..audio.utils import AudioUtils
|
||||
from ..shared.history_logger import HistoryLogger
|
||||
from ..audio.sources import AudioSource
|
||||
from ..infrastructure.validation.validators import FileValidator, ValidationError
|
||||
|
||||
logger = logging.getLogger('app.transcription_service')
|
||||
|
||||
|
||||
class TranscriptionService:
|
||||
"""
|
||||
Сервис для обработки и транскрибации аудиофайлов.
|
||||
|
||||
Attributes:
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config (Dict): Словарь с конфигурацией.
|
||||
max_file_size_mb (int): Максимальный размер файла в МБ.
|
||||
history (HistoryLogger): Объект журналирования.
|
||||
"""
|
||||
|
||||
def __init__(self, transcriber, config: Dict):
|
||||
"""
|
||||
Инициализация сервиса транскрибации.
|
||||
|
||||
Args:
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
"""
|
||||
self.transcriber = transcriber
|
||||
self.config = config
|
||||
self.max_file_size_mb = self.config.get("file_validation", {}).get("max_file_size_mb", 100)
|
||||
|
||||
# Объект журналирования
|
||||
self.history = HistoryLogger(config)
|
||||
|
||||
def transcribe_from_source(self, source: AudioSource, params: Dict = None, file_validator: FileValidator = None) -> Tuple[Dict, int]:
|
||||
"""
|
||||
Транскрибирует аудиофайл из указанного источника.
|
||||
|
||||
Args:
|
||||
source: Источник аудиофайла.
|
||||
params: Дополнительные параметры для транскрибации.
|
||||
file_validator: Валидатор файлов.
|
||||
|
||||
Returns:
|
||||
Кортеж (JSON-ответ, HTTP-код).
|
||||
"""
|
||||
# Получаем файл из источника
|
||||
file, filename, error = source.get_audio_file()
|
||||
|
||||
# Обрабатываем ошибки получения файла
|
||||
if error:
|
||||
logger.warning(f"Ошибка получения файла из источника: {error}")
|
||||
return {"error": error}, 400
|
||||
|
||||
if not file:
|
||||
logger.warning("Не удалось получить аудиофайл из источника")
|
||||
return {"error": "Failed to get audio file"}, 400
|
||||
|
||||
# Валидация файла, если предоставлен валидатор
|
||||
if file_validator:
|
||||
try:
|
||||
file_validator.validate_file(file, filename)
|
||||
except ValidationError as e:
|
||||
# Логирование ошибки валидации
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}")
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
# Извлекаем параметры из запроса, если они есть
|
||||
params = params or {}
|
||||
language = params.get('language', self.config.get('language', 'en'))
|
||||
temperature = float(params.get('temperature', 0.0))
|
||||
prompt = params.get('prompt', '')
|
||||
|
||||
# Проверяем, запрошены ли временные метки
|
||||
return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False))
|
||||
# Преобразуем строковое значение в булево, если необходимо
|
||||
if isinstance(return_timestamps, str):
|
||||
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
|
||||
|
||||
# Временно изменяем настройку return_timestamps в транскрайбере
|
||||
original_return_timestamps = self.transcriber.return_timestamps
|
||||
self.transcriber.return_timestamps = return_timestamps
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
from ..infrastructure.storage.file_manager import temp_file_manager
|
||||
with temp_file_manager.temp_file() as temp_file_path:
|
||||
file.save(temp_file_path)
|
||||
|
||||
# Определяем длительность аудиофайла
|
||||
try:
|
||||
duration = AudioUtils.get_audio_duration(temp_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при определении длительности файла: {e}")
|
||||
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
|
||||
|
||||
# Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку
|
||||
if hasattr(source, 'cleanup'):
|
||||
file.file.close() # Закрываем файловый объект
|
||||
source.cleanup() # Очищаем временные файлы источника
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
result = self.transcriber.process_file(temp_file_path)
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
# Формируем ответ в зависимости от return_timestamps
|
||||
if return_timestamps:
|
||||
response = {
|
||||
"segments": result.get("segments", []),
|
||||
"text": result.get("text", ""),
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
else:
|
||||
# Если не запрашивались временные метки, result - это строка
|
||||
response = {
|
||||
"text": result,
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
|
||||
# Журналирование результата
|
||||
self.history.save(response, filename)
|
||||
|
||||
return response, 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
|
||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
finally:
|
||||
# Восстанавливаем оригинальное значение return_timestamps
|
||||
self.transcriber.return_timestamps = original_return_timestamps
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Модуль manager.py содержит функции для асинхронной обработки задач.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
from typing import Dict, Any, Callable, Optional
|
||||
from threading import Thread
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.async_tasks')
|
||||
|
||||
|
||||
class AsyncTaskManager:
|
||||
"""
|
||||
Менеджер асинхронных задач на основе потоков.
|
||||
|
||||
Attributes:
|
||||
tasks (Dict): Словарь для хранения информации о задачах.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация менеджера асинхронных задач.
|
||||
"""
|
||||
self.tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def run_task(self, func: Callable, *args, **kwargs) -> str:
|
||||
"""
|
||||
Запуск задачи в отдельном потоке.
|
||||
|
||||
Args:
|
||||
func: Функция для выполнения.
|
||||
*args: Позиционные аргументы для функции.
|
||||
**kwargs: Именованные аргументы для функции.
|
||||
|
||||
Returns:
|
||||
ID задачи.
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# Создание информации о задаче
|
||||
self.tasks[task_id] = {
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"error": None,
|
||||
"created_at": time.time(),
|
||||
"started_at": None,
|
||||
"completed_at": None
|
||||
}
|
||||
|
||||
# Создание и запуск потока
|
||||
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return task_id
|
||||
|
||||
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
|
||||
"""
|
||||
Функция для выполнения задачи в потоке.
|
||||
|
||||
Args:
|
||||
task_id: ID задачи.
|
||||
func: Функция для выполнения.
|
||||
args: Позиционные аргументы для функции.
|
||||
kwargs: Именованные аргументы для функции.
|
||||
"""
|
||||
try:
|
||||
# Обновление статуса задачи
|
||||
self.tasks[task_id]["status"] = "running"
|
||||
self.tasks[task_id]["started_at"] = time.time()
|
||||
|
||||
# Выполнение функции
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Сохранение результата
|
||||
self.tasks[task_id]["status"] = "completed"
|
||||
self.tasks[task_id]["result"] = result
|
||||
self.tasks[task_id]["completed_at"] = time.time()
|
||||
|
||||
logger.info(f"Задача {task_id} завершена успешно")
|
||||
except Exception as e:
|
||||
# Обработка ошибки
|
||||
self.tasks[task_id]["status"] = "failed"
|
||||
self.tasks[task_id]["error"] = str(e)
|
||||
self.tasks[task_id]["completed_at"] = time.time()
|
||||
|
||||
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
|
||||
|
||||
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получение статуса задачи.
|
||||
|
||||
Args:
|
||||
task_id: ID задачи.
|
||||
|
||||
Returns:
|
||||
Информация о задаче или None, если задача не найдена.
|
||||
"""
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
def cleanup_completed_tasks(self, max_age_seconds: int = 3600) -> None:
|
||||
"""
|
||||
Очистка завершенных задач старше указанного возраста.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Максимальный возраст задачи в секундах.
|
||||
"""
|
||||
current_time = time.time()
|
||||
tasks_to_remove = []
|
||||
|
||||
for task_id, task_info in self.tasks.items():
|
||||
if (task_info["status"] in ["completed", "failed"] and
|
||||
"completed_at" in task_info and
|
||||
current_time - task_info["completed_at"] > max_age_seconds):
|
||||
tasks_to_remove.append(task_id)
|
||||
|
||||
for task_id in tasks_to_remove:
|
||||
del self.tasks[task_id]
|
||||
logger.debug(f"Задача {task_id} удалена из-за устаревания")
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера асинхронных задач
|
||||
task_manager = AsyncTaskManager()
|
||||
|
||||
|
||||
def transcribe_audio_async(file_path: str, transcriber) -> str:
|
||||
"""
|
||||
Асинхронная транскрибация аудиофайла.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
|
||||
Returns:
|
||||
ID задачи.
|
||||
"""
|
||||
return task_manager.run_task(transcriber.process_file, file_path)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Модуль config.py содержит централизованную настройку логирования.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
|
||||
def setup_logging(log_level=logging.INFO, log_file=None):
|
||||
"""
|
||||
Настройка логирования для всего приложения.
|
||||
|
||||
Args:
|
||||
log_level: Уровень логирования (по умолчанию INFO).
|
||||
log_file: Путь к файлу для записи логов (опционально).
|
||||
"""
|
||||
# Создаем корневой логгер
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Очищаем существующие обработчики
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Создаем улучшенный форматтер с поддержкой дополнительных полей
|
||||
class CustomFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
# Добавляем поле type если оно отсутствует
|
||||
if not hasattr(record, 'type'):
|
||||
record.type = 'general'
|
||||
return super().format(record)
|
||||
|
||||
formatter = CustomFormatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - [%(type)s] %(message)s'
|
||||
)
|
||||
|
||||
# Добавляем обработчик для вывода в консоль
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Добавляем обработчик для записи в файл, если указан путь
|
||||
if log_file:
|
||||
# Создаем директорию для файла логов, если она не существует
|
||||
log_dir = os.path.dirname(log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10*1024*1024, # 10 МБ
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setLevel(log_level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Устанавливаем уровень логирования для логгеров в других модулях
|
||||
logging.getLogger('app').setLevel(log_level)
|
||||
logging.getLogger('app.request').setLevel(log_level)
|
||||
|
||||
return root_logger
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Модуль request_logger.py содержит middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from flask import request, g
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class RequestLogger:
|
||||
"""
|
||||
Middleware для логирования входящих запросов и ответов.
|
||||
"""
|
||||
|
||||
def __init__(self, app=None, config: Optional[Dict] = None):
|
||||
"""
|
||||
Инициализация middleware.
|
||||
|
||||
Args:
|
||||
app: Flask приложение.
|
||||
config: Конфигурация логирования.
|
||||
"""
|
||||
self.app = app
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger('app.request')
|
||||
|
||||
# Чувствительные заголовки для фильтрации
|
||||
self.sensitive_headers = set(self.config.get(
|
||||
'sensitive_headers',
|
||||
['authorization', 'cookie', 'set-cookie', 'proxy-authorization', 'x-api-key']
|
||||
))
|
||||
|
||||
# Эндпоинты для исключения из логирования
|
||||
self.exclude_endpoints = set(self.config.get('exclude_endpoints', ['/health', '/static']))
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""Инициализация middleware с Flask приложением."""
|
||||
app.before_request(self._before_request)
|
||||
app.after_request(self._after_request)
|
||||
|
||||
def _should_log_request(self) -> bool:
|
||||
"""Проверка, нужно ли логировать текущий запрос."""
|
||||
# Проверяем, исключен ли эндпоинт
|
||||
path = request.path
|
||||
for excluded in self.exclude_endpoints:
|
||||
if path.startswith(excluded):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _before_request(self):
|
||||
"""Логирование входящего запроса."""
|
||||
if not self._should_log_request():
|
||||
return
|
||||
|
||||
g.start_time = time.time()
|
||||
|
||||
# Определяем режим логирования
|
||||
debug_mode = self.config.get('log_debug', False)
|
||||
|
||||
# Сбор информации о запросе
|
||||
request_info = self._extract_request_info(debug=debug_mode)
|
||||
|
||||
# Логирование в зависимости от режима
|
||||
if debug_mode:
|
||||
self._log_debug_request(request_info)
|
||||
else:
|
||||
message = self._format_request_message(request_info)
|
||||
self.logger.info(
|
||||
message,
|
||||
extra={"type": "request"}
|
||||
)
|
||||
|
||||
def _after_request(self, response):
|
||||
"""Логирование ответа."""
|
||||
if not self._should_log_request():
|
||||
return response
|
||||
|
||||
# Расчет времени обработки
|
||||
processing_time = time.time() - getattr(g, 'start_time', time.time())
|
||||
|
||||
# Определяем режим логирования
|
||||
debug_mode = self.config.get('log_debug', False)
|
||||
|
||||
# Логирование в зависимости от режима
|
||||
if debug_mode:
|
||||
self._log_debug_response(response, processing_time)
|
||||
else:
|
||||
message = self._format_response_message(response, processing_time)
|
||||
self.logger.info(
|
||||
message,
|
||||
extra={"type": "response"}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _extract_request_info(self, debug: bool = False) -> Dict[str, Any]:
|
||||
"""Извлечение информации о запросе."""
|
||||
# Базовая информация
|
||||
info = {
|
||||
"endpoint": request.endpoint or str(request.url_rule),
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"client_ip": self._get_client_ip(),
|
||||
"user_agent": request.headers.get('User-Agent', 'Unknown')
|
||||
}
|
||||
|
||||
# Параметры запроса
|
||||
if request.args:
|
||||
info["query_params"] = dict(request.args)
|
||||
|
||||
# Данные формы (исключая файлы)
|
||||
if request.form:
|
||||
info["form_data"] = dict(request.form)
|
||||
|
||||
# JSON данные
|
||||
if request.is_json:
|
||||
try:
|
||||
info["json_data"] = request.get_json()
|
||||
except Exception:
|
||||
info["json_data"] = "Invalid JSON"
|
||||
|
||||
# Информация о файлах
|
||||
if request.files:
|
||||
file_info = {}
|
||||
for key, file in request.files.items():
|
||||
file_info[key] = {
|
||||
"filename": file.filename,
|
||||
"content_type": file.content_type,
|
||||
"content_length": len(file.read()) if file else 0
|
||||
}
|
||||
file.seek(0) # Возвращаем указатель файла
|
||||
info["files"] = file_info
|
||||
|
||||
# Заголовки
|
||||
if debug:
|
||||
# В отладочном режиме логируем все заголовки
|
||||
headers = dict(request.headers)
|
||||
else:
|
||||
# В обычном режиме фильтруем чувствительные заголовки
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() not in self.sensitive_headers:
|
||||
headers[key] = value
|
||||
info["headers"] = headers
|
||||
|
||||
return info
|
||||
|
||||
def _log_debug_request(self, request_info: Dict[str, Any]):
|
||||
"""Логирование полных данных запроса в отладочном режиме."""
|
||||
debug_data = {
|
||||
"timestamp": time.time(),
|
||||
"type": "request",
|
||||
"data": request_info
|
||||
}
|
||||
self.logger.info(
|
||||
"DEBUG REQUEST: %s",
|
||||
json.dumps(debug_data, ensure_ascii=False, default=str)
|
||||
)
|
||||
|
||||
def _log_debug_response(self, response, processing_time: float):
|
||||
"""Логирование полных данных ответа в отладочном режиме."""
|
||||
response_info = {
|
||||
"status_code": response.status_code,
|
||||
"headers": dict(response.headers),
|
||||
"content_length": response.content_length,
|
||||
"processing_time": round(processing_time, 3)
|
||||
}
|
||||
debug_data = {
|
||||
"timestamp": time.time(),
|
||||
"type": "response",
|
||||
"data": response_info
|
||||
}
|
||||
self.logger.info(
|
||||
"DEBUG RESPONSE: %s",
|
||||
json.dumps(debug_data, ensure_ascii=False, default=str)
|
||||
)
|
||||
|
||||
def _format_request_message(self, request_info: Dict[str, Any]) -> str:
|
||||
"""Форматирование сообщения с деталями запроса."""
|
||||
# Базовая информация
|
||||
method = request_info.get("method", "UNKNOWN")
|
||||
path = request_info.get("path", "/")
|
||||
client_ip = request_info.get("client_ip", "unknown")
|
||||
user_agent = request_info.get("user_agent", "Unknown")
|
||||
|
||||
# Информация о файлах
|
||||
file_info = ""
|
||||
if "files" in request_info and request_info["files"]:
|
||||
file_details = []
|
||||
for file_key, file_data in request_info["files"].items():
|
||||
filename = file_data.get("filename", "unknown")
|
||||
size = file_data.get("content_length", 0)
|
||||
file_details.append(f"{filename} ({size} байт)")
|
||||
file_info = f" файлы: {', '.join(file_details)}"
|
||||
|
||||
# Информация о параметрах (только имена для безопасности)
|
||||
param_info = ""
|
||||
if "query_params" in request_info and request_info["query_params"]:
|
||||
param_names = list(request_info["query_params"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
elif "form_data" in request_info and request_info["form_data"]:
|
||||
param_names = list(request_info["form_data"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
elif "json_data" in request_info and isinstance(request_info["json_data"], dict):
|
||||
param_names = list(request_info["json_data"].keys())
|
||||
param_info = f" параметры: {', '.join(param_names)}"
|
||||
|
||||
# Формирование полного сообщения
|
||||
message = f"{method} {path} от {client_ip} ({user_agent}){file_info}{param_info}"
|
||||
|
||||
return message.strip()
|
||||
|
||||
def _format_response_message(self, response, processing_time: float) -> str:
|
||||
"""Форматирование сообщения с деталями ответа."""
|
||||
status_code = response.status_code
|
||||
content_length = response.content_length or 0
|
||||
processing_time_rounded = round(processing_time, 3)
|
||||
|
||||
return f"{status_code} за {processing_time_rounded} сек, {content_length} байт"
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
"""Получение реального IP адреса клиента."""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0]
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
else:
|
||||
return request.remote_addr or 'unknown'
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Модуль cache.py содержит функции для кэширования данных.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.cache')
|
||||
|
||||
|
||||
class SimpleCache:
|
||||
"""
|
||||
Простой кэш на основе словаря с поддержкой TTL (Time To Live).
|
||||
|
||||
Attributes:
|
||||
cache (Dict): Словарь для хранения кэшированных данных.
|
||||
ttl (int): Время жизни кэша в секундах.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl: int = 300):
|
||||
"""
|
||||
Инициализация кэша.
|
||||
|
||||
Args:
|
||||
ttl: Время жизни кэша в секундах (по умолчанию 5 минут).
|
||||
"""
|
||||
self.cache: Dict[str, Dict[str, Any]] = {}
|
||||
self.ttl = ttl
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Получение значения из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для получения значения.
|
||||
|
||||
Returns:
|
||||
Кэшированное значение или None, если ключ не найден или срок действия истек.
|
||||
"""
|
||||
if key in self.cache:
|
||||
item = self.cache[key]
|
||||
if time.time() - item["timestamp"] < self.ttl:
|
||||
logger.debug(f"Кэш hit для ключа: {key}")
|
||||
return item["value"]
|
||||
else:
|
||||
# Удаление просроченного элемента
|
||||
del self.cache[key]
|
||||
logger.debug(f"Кэш expired для ключа: {key}")
|
||||
|
||||
logger.debug(f"Кэш miss для ключа: {key}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Установка значения в кэш.
|
||||
|
||||
Args:
|
||||
key: Ключ для хранения значения.
|
||||
value: Значение для кэширования.
|
||||
"""
|
||||
self.cache[key] = {
|
||||
"value": value,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
logger.debug(f"Значение кэшировано для ключа: {key}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Очистка кэша.
|
||||
"""
|
||||
self.cache.clear()
|
||||
logger.debug("Кэш очищен")
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Удаление значения из кэша.
|
||||
|
||||
Args:
|
||||
key: Ключ для удаления.
|
||||
|
||||
Returns:
|
||||
True, если ключ был удален, иначе False.
|
||||
"""
|
||||
if key in self.cache:
|
||||
del self.cache[key]
|
||||
logger.debug(f"Значение удалено из кэша для ключа: {key}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Глобальные экземпляры кэша
|
||||
model_cache = SimpleCache(ttl=3600) # Кэш для метаданных модели (1 час)
|
||||
config_cache = SimpleCache(ttl=300) # Кэш для конфигурации (5 минут)
|
||||
|
||||
|
||||
def cache_result(cache_instance: SimpleCache, key_prefix: str = ""):
|
||||
"""
|
||||
Декоратор для кэширования результатов функции.
|
||||
|
||||
Args:
|
||||
cache_instance: Экземпляр кэша.
|
||||
key_prefix: Префикс для ключа кэша.
|
||||
|
||||
Returns:
|
||||
Декорированная функция.
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Генерация ключа кэша на основе имени функции и аргументов
|
||||
cache_key = f"{key_prefix}{func.__name__}_{str(args)}_{str(kwargs)}"
|
||||
|
||||
# Попытка получить результат из кэша
|
||||
cached_result = cache_instance.get(cache_key)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
# Если результат не в кэше, вызываем функцию
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Сохраняем результат в кэш
|
||||
cache_instance.set(cache_key, result)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
|
||||
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import contextlib
|
||||
from typing import List, Tuple, Optional, Generator
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.file_manager')
|
||||
|
||||
|
||||
class TempFileManager:
|
||||
"""
|
||||
Класс для централизованного управления временными файлами.
|
||||
|
||||
Предоставляет методы для создания временных файлов и их последующей очистки.
|
||||
Использует контекстные менеджеры для автоматической очистки ресурсов.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Инициализация менеджера временных файлов.
|
||||
"""
|
||||
self.temp_files = []
|
||||
self.temp_dirs = []
|
||||
|
||||
def create_temp_file(self, suffix: str = ".wav") -> Tuple[str, str]:
|
||||
"""
|
||||
Создает временный файл с уникальным именем.
|
||||
|
||||
Args:
|
||||
suffix: Расширение временного файла.
|
||||
|
||||
Returns:
|
||||
Кортеж (путь к файлу, путь к временной директории).
|
||||
"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_file = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
|
||||
|
||||
self.temp_files.append(temp_file)
|
||||
self.temp_dirs.append(temp_dir)
|
||||
|
||||
logger.debug(f"Создан временный файл: {temp_file}")
|
||||
return temp_file, temp_dir
|
||||
|
||||
def cleanup_temp_files(self, file_paths: Optional[List[str]] = None) -> None:
|
||||
"""
|
||||
Очищает временные файлы и директории.
|
||||
|
||||
Args:
|
||||
file_paths: Список путей к файлам для очистки. Если None, очищает все отслеживаемые файлы.
|
||||
"""
|
||||
paths_to_clean = file_paths if file_paths is not None else self.temp_files
|
||||
|
||||
for path in paths_to_clean:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.debug(f"Удален временный файл: {path}")
|
||||
|
||||
# Попытка удалить директорию, если она пуста
|
||||
temp_dir = os.path.dirname(path)
|
||||
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
logger.debug(f"Удалена временная директория: {temp_dir}")
|
||||
|
||||
# Удаление из списка отслеживаемых директорий
|
||||
if temp_dir in self.temp_dirs:
|
||||
self.temp_dirs.remove(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
|
||||
|
||||
# Удаление файлов из списка отслеживаемых
|
||||
if file_paths is None:
|
||||
self.temp_files.clear()
|
||||
else:
|
||||
for path in file_paths:
|
||||
if path in self.temp_files:
|
||||
self.temp_files.remove(path)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def temp_file(self, suffix: str = ".wav") -> Generator[str, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для создания и автоматической очистки временного файла.
|
||||
|
||||
Args:
|
||||
suffix: Расширение временного файла.
|
||||
|
||||
Yields:
|
||||
Путь к временному файлу.
|
||||
"""
|
||||
temp_file, _ = self.create_temp_file(suffix)
|
||||
try:
|
||||
yield temp_file
|
||||
finally:
|
||||
self.cleanup_temp_files([temp_file])
|
||||
|
||||
def cleanup_all(self) -> None:
|
||||
"""
|
||||
Очищает все отслеживаемые временные файлы и директории.
|
||||
"""
|
||||
self.cleanup_temp_files()
|
||||
for temp_dir in self.temp_dirs:
|
||||
try:
|
||||
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
logger.debug(f"Удалена временная директория: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось очистить временную директорию {temp_dir}: {e}")
|
||||
self.temp_dirs.clear()
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера временных файлов
|
||||
temp_file_manager = TempFileManager()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Модуль validators.py содержит классы и функции для валидации входных данных.
|
||||
"""
|
||||
|
||||
import os
|
||||
import magic
|
||||
from typing import Dict, List, BinaryIO, Optional
|
||||
import logging
|
||||
|
||||
# Получаем логгер из централизованной настройки
|
||||
logger = logging.getLogger('app.validators')
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Исключение для ошибок валидации."""
|
||||
pass
|
||||
|
||||
|
||||
class FileValidator:
|
||||
"""
|
||||
Класс для валидации файлов.
|
||||
|
||||
Проверяет тип файла, размер и другие параметры на основе конфигурации.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
"""
|
||||
Инициализация валидатора файлов.
|
||||
|
||||
Args:
|
||||
config: Словарь с параметрами конфигурации.
|
||||
"""
|
||||
self.validation_config = config.get("file_validation", {})
|
||||
self.max_file_size_mb = self.validation_config.get("max_file_size_mb", 100)
|
||||
self.allowed_extensions = self.validation_config.get("allowed_extensions",
|
||||
[".wav", ".mp3", ".ogg", ".flac", ".m4a"])
|
||||
self.allowed_mime_types = self.validation_config.get("allowed_mime_types",
|
||||
["audio/wav", "audio/mpeg", "audio/ogg",
|
||||
"audio/flac", "audio/mp4"])
|
||||
|
||||
def validate_file(self, file: BinaryIO, filename: str) -> bool:
|
||||
"""
|
||||
Валидирует файл на основе конфигурации.
|
||||
|
||||
Args:
|
||||
file: Файловый объект.
|
||||
filename: Имя файла.
|
||||
|
||||
Returns:
|
||||
True, если файл прошел валидацию.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если файл не прошел валидацию.
|
||||
"""
|
||||
try:
|
||||
# Проверка размера файла
|
||||
self._validate_file_size(file)
|
||||
|
||||
# Проверка расширения файла
|
||||
self._validate_file_extension(filename)
|
||||
|
||||
# Проверка MIME-типа файла
|
||||
self._validate_file_mime_type(file)
|
||||
|
||||
return True
|
||||
except ValidationError as e:
|
||||
# Логирование общей ошибки валидации
|
||||
logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}")
|
||||
raise
|
||||
|
||||
def _validate_file_size(self, file: BinaryIO) -> None:
|
||||
"""
|
||||
Валидирует размер файла.
|
||||
|
||||
Args:
|
||||
file: Файловый объект.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если размер файла превышает максимально допустимый.
|
||||
"""
|
||||
# Сохранение текущей позиции
|
||||
current_position = file.tell()
|
||||
|
||||
# Переход в конец файла для определения размера
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_length = file.tell()
|
||||
|
||||
# Возврат к исходной позиции
|
||||
file.seek(current_position)
|
||||
|
||||
max_size_bytes = self.max_file_size_mb * 1024 * 1024
|
||||
if file_length > max_size_bytes:
|
||||
logger.warning(f"Попытка загрузки файла размером {file_length / (1024*1024):.2f} МБ, "
|
||||
f"что превышает максимально допустимый размер {self.max_file_size_mb} МБ")
|
||||
|
||||
raise ValidationError(f"Размер файла ({file_length / (1024*1024):.2f} МБ) "
|
||||
f"превышает максимально допустимый ({self.max_file_size_mb} МБ)")
|
||||
|
||||
def _validate_file_extension(self, filename: str) -> None:
|
||||
"""
|
||||
Валидирует расширение файла.
|
||||
|
||||
Args:
|
||||
filename: Имя файла.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если расширение файла не входит в список разрешенных.
|
||||
"""
|
||||
if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
|
||||
# Логирование попытки загрузки файла с неразрешенным расширением
|
||||
file_extension = os.path.splitext(filename)[1]
|
||||
logger.warning(f"Попытка загрузки файла с неразрешенным расширением '{file_extension}'. "
|
||||
f"Имя файла: {filename}. Разрешенные расширения: {', '.join(self.allowed_extensions)}")
|
||||
|
||||
raise ValidationError(f"Расширение файла не разрешено. "
|
||||
f"Разрешенные расширения: {', '.join(self.allowed_extensions)}")
|
||||
|
||||
def _validate_file_mime_type(self, file: BinaryIO) -> None:
|
||||
"""
|
||||
Валидирует MIME-тип файла.
|
||||
|
||||
Args:
|
||||
file: Файловый объект.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если MIME-тип файла не входит в список разрешенных.
|
||||
"""
|
||||
# Сохранение текущей позиции
|
||||
current_position = file.tell()
|
||||
|
||||
try:
|
||||
# Чтение первых байтов для определения MIME-типа
|
||||
header = file.read(1024)
|
||||
mime_type = magic.from_buffer(header, mime=True)
|
||||
|
||||
# Возврат к исходной позиции
|
||||
file.seek(current_position)
|
||||
|
||||
if mime_type not in self.allowed_mime_types:
|
||||
# Логирование попытки загрузки файла с неразрешенным MIME-типом
|
||||
logger.warning(f"Попытка загрузки файла с неразрешенным MIME-типом '{mime_type}'. "
|
||||
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
|
||||
|
||||
raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. "
|
||||
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
|
||||
except Exception as e:
|
||||
# Возврат к исходной позиции в случае ошибки
|
||||
file.seek(current_position)
|
||||
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
|
||||
# Не прерываем валидацию, если не удалось определить MIME-тип
|
||||
|
||||
@staticmethod
|
||||
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
|
||||
"""
|
||||
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу.
|
||||
allowed_directories: Список разрешенных директорий.
|
||||
|
||||
Returns:
|
||||
Нормализованный и проверенный путь к файлу.
|
||||
|
||||
Raises:
|
||||
ValidationError: Если путь к файлу небезопасен.
|
||||
"""
|
||||
# Нормализация пути
|
||||
normalized_path = os.path.normpath(file_path)
|
||||
|
||||
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
|
||||
if allowed_directories:
|
||||
for allowed_dir in allowed_directories:
|
||||
full_allowed_path = os.path.abspath(allowed_dir)
|
||||
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
|
||||
|
||||
if full_file_path.startswith(full_allowed_path):
|
||||
return full_file_path
|
||||
|
||||
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
|
||||
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
|
||||
|
||||
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
|
||||
return normalized_path
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Модуль context_managers.py содержит контекстные менеджеры для управления ресурсами.
|
||||
"""
|
||||
|
||||
import os
|
||||
import contextlib
|
||||
from typing import Generator, BinaryIO
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.context_managers')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_file(file_path: str, mode: str = 'rb') -> Generator[BinaryIO, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для безопасного открытия и закрытия файлов.
|
||||
|
||||
Args:
|
||||
file_path: Путь к файлу.
|
||||
mode: Режим открытия файла.
|
||||
|
||||
Yields:
|
||||
Файловый объект.
|
||||
"""
|
||||
file_obj = None
|
||||
try:
|
||||
file_obj = open(file_path, mode)
|
||||
yield file_obj
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с файлом {file_path}: {e}")
|
||||
raise
|
||||
finally:
|
||||
if file_obj:
|
||||
file_obj.close()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def audio_file(file_path: str) -> Generator[BinaryIO, None, None]:
|
||||
"""
|
||||
Контекстный менеджер для работы с аудиофайлами.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Yields:
|
||||
Файловый объект в бинарном режиме.
|
||||
"""
|
||||
with open_file(file_path, 'rb') as f:
|
||||
yield f
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Модуль decorators.py содержит общие декораторы для использования в приложении.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import functools
|
||||
from flask import request
|
||||
|
||||
# Получаем логгер из централизованной настройки
|
||||
logger = logging.getLogger('app.utils')
|
||||
|
||||
|
||||
def log_invalid_file_request(func):
|
||||
"""
|
||||
Декоратор для логирования запросов с невалидными файлами.
|
||||
|
||||
Args:
|
||||
func: Декорируемая функция.
|
||||
|
||||
Returns:
|
||||
Обернутая функция с логированием ошибок валидации файлов.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# Получение информации о запросе
|
||||
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
|
||||
endpoint = request.endpoint or 'unknown'
|
||||
method = request.method or 'unknown'
|
||||
|
||||
# Получение имени файла из запроса
|
||||
filename = 'unknown'
|
||||
if 'file' in request.files:
|
||||
filename = request.files['file'].filename
|
||||
elif request.is_json:
|
||||
data = request.get_json()
|
||||
if data and 'file' in data:
|
||||
filename = data.get('filename', 'base64_data')
|
||||
|
||||
# Логирование обращения к API с невалидным файлом
|
||||
logger.warning(f"Обращение к эндпоинту {method} {endpoint} с невалидным файлом '{filename}' "
|
||||
f"от клиента {client_ip}. Ошибка: {str(e)}")
|
||||
|
||||
# Пробрасываем исключение дальше
|
||||
raise
|
||||
return wrapper
|
||||
@@ -9,8 +9,11 @@ import datetime
|
||||
import random
|
||||
import string
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
from .utils import logger
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('app.history')
|
||||
|
||||
class HistoryLogger:
|
||||
"""Класс для сохранения истории транскрибации."""
|
||||
@@ -82,4 +85,4 @@ class HistoryLogger:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении истории транскрибации: {e}")
|
||||
return None
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
Модуль transcriber_service.py содержит класс TranscriptionService,
|
||||
который отвечает за обработку и транскрибацию аудиофайлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import time
|
||||
import librosa
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from .utils import logger
|
||||
from .history_logger import HistoryLogger
|
||||
from .audio_sources import AudioSource
|
||||
|
||||
|
||||
class TranscriptionService:
|
||||
"""Сервис для обработки и транскрибации аудиофайлов."""
|
||||
|
||||
def __init__(self, transcriber, config: Dict):
|
||||
"""
|
||||
Инициализация сервиса транскрибации.
|
||||
|
||||
Args:
|
||||
transcriber: Экземпляр транскрайбера.
|
||||
config: Словарь с конфигурацией.
|
||||
"""
|
||||
self.transcriber = transcriber
|
||||
self.config = config
|
||||
self.max_file_size_mb = self.config.get("max_file_size", 100) # Default 100MB
|
||||
|
||||
# Объект журналирования
|
||||
self.history = HistoryLogger(config)
|
||||
|
||||
def get_audio_duration(self, file_path: str) -> float:
|
||||
"""
|
||||
Определяет длительность аудиофайла в секундах.
|
||||
|
||||
Args:
|
||||
file_path: Путь к аудиофайлу.
|
||||
|
||||
Returns:
|
||||
Длительность в секундах.
|
||||
"""
|
||||
try:
|
||||
y, sr = librosa.load(file_path, sr=None)
|
||||
duration = librosa.get_duration(y=y, sr=sr)
|
||||
return duration
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при определении длительности файла: {e}")
|
||||
return 0.0
|
||||
|
||||
def transcribe_from_source(self, source: AudioSource, params: Dict = None) -> Tuple[Dict, int]:
|
||||
"""
|
||||
Транскрибирует аудиофайл из указанного источника.
|
||||
|
||||
Args:
|
||||
source: Источник аудиофайла.
|
||||
params: Дополнительные параметры для транскрибации.
|
||||
|
||||
Returns:
|
||||
Кортеж (JSON-ответ, HTTP-код).
|
||||
"""
|
||||
# Получаем файл из источника
|
||||
file, filename, error = source.get_audio_file()
|
||||
|
||||
# Обрабатываем ошибки получения файла
|
||||
if error:
|
||||
return {"error": error}, 400
|
||||
|
||||
if not file:
|
||||
return {"error": "Failed to get audio file"}, 400
|
||||
|
||||
# Извлекаем параметры из запроса, если они есть
|
||||
params = params or {}
|
||||
language = params.get('language', self.config.get('language', 'en'))
|
||||
temperature = float(params.get('temperature', 0.0))
|
||||
prompt = params.get('prompt', '')
|
||||
|
||||
# Проверяем, запрошены ли временные метки
|
||||
return_timestamps = params.get('return_timestamps', self.config.get('return_timestamps', False))
|
||||
# Преобразуем строковое значение в булево, если необходимо
|
||||
if isinstance(return_timestamps, str):
|
||||
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
|
||||
|
||||
# Временно изменяем настройку return_timestamps в транскрайбере
|
||||
original_return_timestamps = self.transcriber.return_timestamps
|
||||
self.transcriber.return_timestamps = return_timestamps
|
||||
|
||||
# Сохраняем файл во временный файл
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_file_path = os.path.join(temp_dir, str(uuid.uuid4()) + "_" + os.path.basename(filename))
|
||||
file.save(temp_file_path)
|
||||
|
||||
# Определяем длительность аудиофайла
|
||||
duration = self.get_audio_duration(temp_file_path)
|
||||
|
||||
# Для файлов из внешних источников (URL, base64), закрываем их и выполняем очистку
|
||||
if hasattr(source, 'cleanup'):
|
||||
file.file.close() # Закрываем файловый объект
|
||||
source.cleanup() # Очищаем временные файлы источника
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
result = self.transcriber.process_file(temp_file_path)
|
||||
processing_time = time.time() - start_time
|
||||
|
||||
# Формируем ответ в зависимости от return_timestamps
|
||||
if return_timestamps:
|
||||
response = {
|
||||
"segments": result.get("segments", []),
|
||||
"text": result.get("text", ""),
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
else:
|
||||
# Если не запрашивались временные метки, result - это строка
|
||||
response = {
|
||||
"text": result,
|
||||
"processing_time": processing_time,
|
||||
"response_size_bytes": len(str(result).encode('utf-8')),
|
||||
"duration_seconds": duration,
|
||||
"model": os.path.basename(self.config["model_path"])
|
||||
}
|
||||
|
||||
# Журналирование результата
|
||||
self.history.save(response, filename)
|
||||
|
||||
return response, 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при транскрибации: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
finally:
|
||||
# Восстанавливаем оригинальное значение return_timestamps
|
||||
self.transcriber.return_timestamps = original_return_timestamps
|
||||
|
||||
# Очистка временных файлов
|
||||
if os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
if os.path.exists(temp_dir):
|
||||
os.rmdir(temp_dir)
|
||||
@@ -1,12 +0,0 @@
|
||||
import logging
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler("logs/transcribe.log")
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("whisper-service")
|
||||
Reference in New Issue
Block a user