Refactor project structure and add new features

This commit is contained in:
Serge Zaigraeff
2025-10-23 00:42:09 +03:00
parent 144414fcf8
commit 852f7975b9
63 changed files with 8101 additions and 1341 deletions
+104
View File
@@ -0,0 +1,104 @@
"""
Главный модуль приложения, содержащий класс WhisperServiceAPI для инициализации
и запуска сервиса распознавания речи.
"""
import os
import logging
from flask import Flask
from typing import Dict
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
class WhisperServiceAPI:
"""
Класс для API сервиса распознавания речи.
Attributes:
config (Dict): Словарь с параметрами конфигурации.
port (int): Порт для сервиса.
transcriber (WhisperTranscriber): Экземпляр транскрайбера.
app (Flask): Flask-приложение.
file_validator (FileValidator): Валидатор файлов.
"""
def __init__(self, config_path: str):
"""
Инициализация API сервиса.
Args:
config_path: Путь к конфигурационному файлу.
"""
# Загрузка конфигурации
self.config = load_config(config_path)
# Установка уровня логирования
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
+137
View File
@@ -0,0 +1,137 @@
"""
Модуль async_tasks.py содержит функции для асинхронной обработки задач.
"""
import uuid
import time
from typing import Dict, Any, Callable, Optional
from threading import Thread
from .utils import logger
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)
+240
View File
@@ -0,0 +1,240 @@
"""
Модуль audio_processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
"""
import os
import subprocess
import uuid
from typing import Dict, Tuple
from .file_manager import temp_file_manager
from .context_managers import open_file
from .utils import logger
class AudioProcessor:
"""
Класс для предобработки аудиофайлов перед распознаванием.
Attributes:
config (Dict): Словарь с параметрами конфигурации.
norm_level (str): Уровень нормализации аудио.
compand_params (str): Параметры компрессора аудио.
"""
def __init__(self, config: Dict):
"""
Инициализация обработчика аудио.
Args:
config: Словарь с параметрами конфигурации.
"""
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:
"""
Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
Путь к сконвертированному WAV-файлу.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при конвертации.
"""
audio_rate = self.config["audio_rate"]
# Проверка расширения файла
if input_path.lower().endswith('.wav'):
# Проверяем, нужно ли преобразовывать WAV-файл (например, если частота не 16 кГц)
try:
info = subprocess.check_output(['soxi', input_path]).decode()
if f'{audio_rate} Hz' in info:
logger.info(f"Файл {input_path} уже в формате WAV с частотой {audio_rate} Гц")
return input_path
except subprocess.CalledProcessError:
logger.warning(f"Не удалось получить информацию о WAV-файле {input_path}")
# Продолжаем конвертацию, чтобы быть уверенными в формате
# Создаем временный файл для WAV
output_path, _ = temp_file_manager.create_temp_file(".wav")
# Команда для конвертации
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-i", input_path,
"-ar", f"{audio_rate}",
"-ac", "1", # Монофонический звук
output_path
]
logger.info(f"Конвертация в WAV: {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True, capture_output=True)
logger.info(f"Файл конвертирован в WAV: {output_path}")
return output_path
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при конвертации в WAV: {e.stderr.decode()}")
raise
def normalize_audio(self, input_path: str) -> str:
"""
Нормализация аудиофайла с использованием sox.
Args:
input_path: Путь к WAV-файлу.
Returns:
Путь к нормализованному WAV-файлу.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при нормализации.
"""
# Создаем временный файл для нормализованного аудио
output_path, _ = temp_file_manager.create_temp_file("_normalized.wav")
# Команда для нормализации аудио с помощью sox
cmd = [
"sox",
input_path,
output_path,
"norm", self.norm_level,
"compand"
] + self.compand_params.split()
logger.info(f"Нормализация аудио: {' '.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 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:
"""
Добавляет тишину в начало аудиофайла.
Args:
input_path: Путь к аудиофайлу.
Returns:
Путь к аудиофайлу с добавленной тишиной.
Raises:
subprocess.CalledProcessError: Если произошла ошибка при добавлении тишины.
"""
# Создаем временный файл
output_path, _ = temp_file_manager.create_temp_file("_silence.wav")
# Команда для добавления тишины в начало файла
cmd = [
"sox",
input_path,
output_path,
"pad", "2.0", "1.0" # Добавление тишины в начале и в конце (секунды)
]
logger.info(f"Добавление тишины: {' '.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 process_audio(self, input_path: str) -> Tuple[str, list]:
"""
Полная обработка аудиофайла: конвертация, нормализация и добавление тишины.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
Raises:
Exception: Если произошла ошибка при обработке аудио.
"""
temp_files = []
try:
# Конвертация в WAV
wav_path = self.convert_to_wav(input_path)
if wav_path != input_path: # Если был создан временный файл
temp_files.append(wav_path)
# Нормализация
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(speedup_path)
temp_files.append(silence_path)
return silence_path, temp_files
except Exception as e:
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
temp_file_manager.cleanup_temp_files(temp_files)
raise
+311
View File
@@ -0,0 +1,311 @@
"""
Модуль audio_sources.py содержит абстрактный класс AudioSource и его конкретные реализации
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
"""
import os
import uuid
import tempfile
import base64
import requests
import abc
from typing import Dict, Tuple, Optional, BinaryIO
from .utils import logger
class AudioSource(abc.ABC):
"""Абстрактный класс для различных источников аудиофайлов.
Определяет интерфейс для различных источников аудио и предоставляет общие
методы для работы с аудиофайлами, такие как проверка размера файла.
"""
def __init__(self, max_file_size_mb: int = 100):
"""
Инициализация источника аудио.
Args:
max_file_size_mb: Максимальный размер файла в МБ.
"""
self.max_file_size_mb = max_file_size_mb
@abc.abstractmethod
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает аудиофайл из источника.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
В случае ошибки, возвращает (None, None, сообщение об ошибке).
"""
pass
def check_file_size(self, file: BinaryIO) -> Tuple[bool, Optional[str]]:
"""
Проверяет размер файла.
Args:
file: Файловый объект для проверки.
Returns:
Кортеж (результат проверки, сообщение об ошибке).
Если проверка пройдена, сообщение об ошибке будет None.
"""
file.seek(0, os.SEEK_END)
file_length = file.tell()
file.seek(0) # Сброс указателя файла после проверки размера
if file_length > self.max_file_size_mb * 1024 * 1024:
return False, f"File exceeds maximum size of {self.max_file_size_mb}MB"
return True, None
class FakeFile:
"""Имитирует файловый объект для унификации обработки из разных источников.
Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64)
как стандартные файловые объекты Flask, обеспечивая совместимость с существующей
логикой обработки файлов.
"""
def __init__(self, file: BinaryIO, filename: str):
"""
Инициализация объекта FakeFile.
Args:
file: Исходный файловый объект или поток.
filename: Имя файла для метаданных.
"""
self.file = file
self.filename = filename
def read(self):
"""Чтение содержимого файла."""
return self.file.read()
def seek(self, offset: int, whence: int = 0):
"""Перемещение позиции чтения."""
self.file.seek(offset, whence)
def tell(self):
"""Получение текущей позиции чтения."""
return self.file.tell()
def save(self, destination: str):
"""
Сохраняет содержимое файла в указанное место назначения.
Args:
destination: Путь для сохранения файла.
"""
with open(destination, 'wb') as f:
content = self.file.read()
f.write(content)
self.file.seek(0) # Сброс указателя после чтения
@property
def name(self):
"""Возвращает имя файла."""
return self.filename
class UploadedFileSource(AudioSource):
"""Источник аудио для файлов, загруженных через HTTP-запрос."""
def __init__(self, request_files, max_file_size_mb: int = 100):
"""
Инициализация источника для загруженных файлов.
Args:
request_files: Объект request.files из Flask.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.request_files = request_files
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает аудиофайл из загруженных файлов.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
"""
if 'file' not in self.request_files:
return None, None, "No file part"
file = self.request_files['file']
if file.filename == '':
return None, None, "No selected file"
# Проверка размера файла
is_valid, error_message = self.check_file_size(file)
if not is_valid:
return None, None, error_message
return file, file.filename, None
class URLSource(AudioSource):
"""Источник аудио для файлов, доступных по URL."""
def __init__(self, url: str, max_file_size_mb: int = 100):
"""
Инициализация источника для файлов по URL.
Args:
url: URL аудиофайла.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.url = url
self.temp_file_path = None
self.temp_dir = None
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает аудиофайл по URL.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
"""
try:
# Скачиваем файл по URL
response = requests.get(self.url, stream=True)
response.raise_for_status()
# Проверка размера файла (если сервер предоставил информацию о размере)
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > self.max_file_size_mb * 1024 * 1024:
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
# Сохраняем файл во временный файл
self.temp_dir = tempfile.mkdtemp()
self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav")
with open(self.temp_file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# Открываем файл для обработки
file = open(self.temp_file_path, 'rb')
# Создаем объект файла, как будто он пришел из request.files
fake_file = FakeFile(file, os.path.basename(self.temp_file_path))
return fake_file, fake_file.filename, None
except Exception as e:
logger.error(f"Ошибка при получении файла по URL {self.url}: {e}")
self.cleanup()
return None, None, f"Error retrieving file from URL: {str(e)}"
def cleanup(self):
"""Очищает временные файлы и директории."""
if self.temp_file_path and os.path.exists(self.temp_file_path):
os.remove(self.temp_file_path)
if self.temp_dir and os.path.exists(self.temp_dir):
os.rmdir(self.temp_dir)
class Base64Source(AudioSource):
"""Источник аудио для файлов, закодированных в base64."""
def __init__(self, base64_data: str, max_file_size_mb: int = 100):
"""
Инициализация источника для base64 файлов.
Args:
base64_data: Данные аудиофайла в формате base64.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.base64_data = base64_data
self.temp_file_path = None
self.temp_dir = None
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает аудиофайл из base64 данных.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
"""
try:
# Декодируем base64
audio_data = base64.b64decode(self.base64_data)
# Проверка размера файла
if len(audio_data) > self.max_file_size_mb * 1024 * 1024:
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
# Сохраняем файл во временный файл
self.temp_dir = tempfile.mkdtemp()
self.temp_file_path = os.path.join(self.temp_dir, str(uuid.uuid4()) + ".wav")
with open(self.temp_file_path, 'wb') as f:
f.write(audio_data)
# Открываем файл для обработки
file = open(self.temp_file_path, 'rb')
# Создаем объект файла, как будто он пришел из request.files
fake_file = FakeFile(file, os.path.basename(self.temp_file_path))
return fake_file, fake_file.filename, None
except Exception as e:
logger.error(f"Ошибка при декодировании base64 данных: {e}")
self.cleanup()
return None, None, f"Error decoding base64 data: {str(e)}"
def cleanup(self):
"""Очищает временные файлы и директории."""
if self.temp_file_path and os.path.exists(self.temp_file_path):
os.remove(self.temp_file_path)
if self.temp_dir and os.path.exists(self.temp_dir):
os.rmdir(self.temp_dir)
class LocalFileSource(AudioSource):
"""Источник аудио для локальных файлов на сервере."""
def __init__(self, file_path: str, max_file_size_mb: int = 100):
"""
Инициализация источника для локальных файлов.
Args:
file_path: Путь к локальному файлу.
max_file_size_mb: Максимальный размер файла в МБ.
"""
super().__init__(max_file_size_mb)
self.file_path = file_path
def get_audio_file(self) -> Tuple[Optional[BinaryIO], Optional[str], Optional[str]]:
"""
Получает локальный аудиофайл.
Returns:
Кортеж (файловый объект, имя файла, сообщение об ошибке).
"""
if not os.path.exists(self.file_path):
return None, None, f"File not found: {self.file_path}"
try:
# Проверка размера файла
file_size = os.path.getsize(self.file_path)
if file_size > self.max_file_size_mb * 1024 * 1024:
return None, None, f"File exceeds maximum size of {self.max_file_size_mb}MB"
# Открываем файл для обработки
file = open(self.file_path, 'rb')
# Создаем объект файла, как будто он пришел из request.files
fake_file = FakeFile(file, os.path.basename(self.file_path))
return fake_file, fake_file.filename, None
except Exception as e:
logger.error(f"Ошибка при открытии локального файла {self.file_path}: {e}")
return None, None, f"Error opening local file: {str(e)}"
+109
View File
@@ -0,0 +1,109 @@
"""
Модуль audio_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}")
+126
View File
@@ -0,0 +1,126 @@
"""
Модуль cache.py содержит функции для кэширования данных.
"""
import time
from typing import Dict, Any, Optional, Callable
from functools import wraps
from .utils import logger
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,47 @@
"""
Модуль context_managers.py содержит контекстные менеджеры для управления ресурсами.
"""
import os
import contextlib
from typing import Generator, BinaryIO
from .utils import logger
@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
+116
View File
@@ -0,0 +1,116 @@
"""
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
"""
import os
import uuid
import tempfile
import contextlib
from typing import List, Tuple, Optional, Generator
from .utils import logger
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,85 @@
"""
Модуль history_logger.py содержит класс HistoryLogger для журналирования результатов
транскрибации.
"""
import os
import json
import datetime
import random
import string
from typing import Dict, Any, Optional
from .utils import logger
class HistoryLogger:
"""Класс для сохранения истории транскрибации."""
def __init__(self, config: Dict):
"""
Инициализация логгера истории.
Args:
config: Словарь с конфигурацией.
"""
self.config = config
self.history_enabled = config.get("enable_history", False)
self.history_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "history")
# Создаем корневую директорию истории, если она не существует
if self.history_enabled and not os.path.exists(self.history_root):
os.makedirs(self.history_root)
logger.info(f"Создана директория для истории транскрибации: {self.history_root}")
def save(self, result: Dict[str, Any], original_filename: str) -> Optional[str]:
"""
Сохраняет результат транскрибации в файл истории.
Args:
result: Результат транскрибации.
original_filename: Исходное имя аудиофайла.
Returns:
Путь к сохраненному файлу истории или None, если сохранение отключено.
"""
if not self.history_enabled:
logger.debug("История транскрибации отключена в конфигурации")
return None
try:
# Получаем текущую дату и время
now = datetime.datetime.now()
date_str = now.strftime("%Y-%m-%d")
# Получаем текущий таймстамп в миллисекундах
timestamp_ms = int(datetime.datetime.now().timestamp() * 1000)
# Генерируем 4-символьную случайную метку
random_tag = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4))
# Получаем только имя файла без пути
base_filename = os.path.basename(original_filename)
# Создаем имя файла истории
history_filename = f"{timestamp_ms}_{base_filename}_{random_tag}.json"
# Путь к директории для текущей даты
date_dir = os.path.join(self.history_root, date_str)
# Создаем директорию для текущей даты, если она не существует
if not os.path.exists(date_dir):
os.makedirs(date_dir)
# Полный путь к файлу истории
history_path = os.path.join(date_dir, history_filename)
# Сохраняем результат в JSON файл
with open(history_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"Результат транскрибации сохранен в историю: {history_path}")
return history_path
except Exception as e:
logger.error(f"Ошибка при сохранении истории транскрибации: {e}")
return None
@@ -0,0 +1,64 @@
"""
Модуль logging_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
+227
View File
@@ -0,0 +1,227 @@
"""
Модуль 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):
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'
+239
View File
@@ -0,0 +1,239 @@
"""
Модуль routes.py содержит классы для регистрации маршрутов API
для сервиса распознавания речи.
"""
import os
from flask import request, jsonify
from typing import Dict
from .transcriber_service import TranscriptionService
from .audio_sources import (
UploadedFileSource,
URLSource,
Base64Source,
LocalFileSource
)
from .validators import ValidationError
from .async_tasks import transcribe_audio_async, task_manager
from .cache import model_cache
from .utils import logger, log_invalid_file_request
class Routes:
"""
Класс для регистрации всех эндпоинтов API.
Attributes:
app (Flask): Flask-приложение.
config (Dict): Словарь с конфигурацией.
transcription_service (TranscriptionService): Сервис транскрибации.
file_validator (FileValidator): Валидатор файлов.
"""
def __init__(self, app, transcriber, config: Dict, file_validator):
"""
Инициализация маршрутов.
Args:
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) -> 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():
"""Эндпоинт для проверки статуса сервиса."""
return jsonify({
"status": "ok",
"version": self.config.get("version", "1.0.0")
}), 200
@self.app.route('/config', methods=['GET'])
def get_config():
"""Эндпоинт для получения конфигурации сервиса."""
return jsonify(self.config), 200
@self.app.route('/local/transcriptions', methods=['POST'])
def local_transcribe():
"""Эндпоинт для локальной транскрибации файла по пути на сервере."""
data = request.json
if not data or "file_path" not in data:
return jsonify({"error": "No file_path provided"}), 400
file_path = data["file_path"]
# Валидация пути к файлу
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
@self.app.route('/v1/models', methods=['GET'])
def list_models():
"""Эндпоинт для получения списка доступных моделей."""
return jsonify({
"data": [
{
"id": os.path.basename(self.config["model_path"]),
"object": "model",
"owned_by": "openai",
"permissions": []
}
],
"object": "list"
}), 200
@self.app.route('/v1/models/<model_id>', methods=['GET'])
def retrieve_model(model_id):
"""Эндпоинт для получения информации о конкретной модели."""
if model_id == os.path.basename(self.config["model_path"]):
return jsonify({
"id": model_id,
"object": "model",
"owned_by": "openai",
"permissions": []
}), 200
else:
return jsonify({
"error": "Model not found",
"details": f"Model '{model_id}' does not exist"
}), 404
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
if not data or "url" not in data:
return jsonify({
"error": "No URL provided",
"details": "Please provide 'url' in the JSON request"
}), 400
url = data["url"]
# Извлекаем параметры транскрибации, если они есть
params = {k: v for k, v in data.items() if k != "url"}
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
if not data or "file" not in data:
return jsonify({
"error": "No base64 file provided",
"details": "Please provide 'file' in the JSON request"
}), 400
base64_data = data["file"]
# Извлекаем параметры транскрибации, если они есть
params = {k: v for k, v in data.items() if k != "file"}
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():
"""Эндпоинт для транскрибации аудиофайла, загруженного через форму."""
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 .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)
+273
View File
@@ -0,0 +1,273 @@
"""
Модуль transcriber.py содержит класс WhisperTranscriber, который использует модель Whisper от
OpenAI для транскрибации аудиофайлов в текст. Класс включает в себя методы для загрузки модели,
обработки аудио (с использованием класса AudioProcessor), и выполнения транскрибации.
Обрабатывает выбор устройства (CPU, CUDA, MPS) для выполнения вычислений и обеспечивает
возможность использования Flash Attention 2 для ускорения работы модели на поддерживаемых GPU.
"""
import time
import traceback
from typing import Dict, Tuple, Union
import numpy as np
import torch
from transformers import (
WhisperForConditionalGeneration,
WhisperProcessor,
pipeline,
)
from .audio_processor import AudioProcessor
from .audio_utils import AudioUtils
from .file_manager import temp_file_manager
from .utils import logger
class WhisperTranscriber:
"""
Класс для распознавания речи с помощью модели 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):
"""
Инициализация транскрайбера.
Args:
config: Словарь с параметрами конфигурации.
"""
self.config = config
self.model_path = config["model_path"]
self.language = config["language"]
self.chunk_length_s = config["chunk_length_s"]
self.batch_size = config["batch_size"]
self.max_new_tokens = config["max_new_tokens"]
self.return_timestamps = config["return_timestamps"]
self.temperature = config["temperature"]
# Оптимальный тип для тензоров
self.torch_dtype = torch.bfloat16
# Создаем объект для обработки аудио
self.audio_processor = AudioProcessor(config)
# Определяем устройство для вычислений
self.device = self._get_device()
# Загружаем модель при инициализации
self._load_model()
def _get_device(self) -> torch.device:
"""
Определение доступного устройства для вычислений.
Returns:
Объект устройства PyTorch.
"""
if torch.cuda.is_available():
# Проверяем, доступна ли GPU с индексом 1
if torch.cuda.device_count() > 1:
logger.info("Используется CUDA GPU с индексом 1 для вычислений")
return torch.device("cuda:1")
else:
logger.info("Доступна только одна CUDA GPU, используется GPU с индексом 0")
return torch.device("cuda:0")
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
logger.info("Используется MPS (Apple Silicon) для вычислений")
# Обходное решение для MPS
setattr(torch.distributed, "is_initialized", lambda: False)
return torch.device("mps")
else:
logger.info("Используется CPU для вычислений")
return torch.device("cpu")
def _load_model(self) -> None:
"""
Загрузка модели и процессора.
Raises:
Exception: Если не удалось загрузить модель.
"""
logger.info(f"Загрузка модели из {self.model_path}")
try:
# Проверка возможности использования Flash Attention 2
if self.device.type == "cuda":
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True,
attn_implementation="flash_attention_2"
).to(self.device)
logger.info("Используется Flash Attention 2")
else:
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
).to(self.device)
except Exception as e:
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
# Fallback к обычной версии
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
).to(self.device)
self.processor = WhisperProcessor.from_pretrained(self.model_path)
self.asr_pipeline = pipeline(
"automatic-speech-recognition",
model=self.model,
tokenizer=self.processor.tokenizer,
feature_extractor=self.processor.feature_extractor,
chunk_length_s=self.chunk_length_s,
batch_size=self.batch_size,
return_timestamps=self.return_timestamps,
torch_dtype=self.torch_dtype,
device=self.device,
)
logger.info("Модель успешно загружена и готова к использованию")
# Метод _load_audio удален, так как его функциональность перенесена в AudioUtils
def transcribe(self, audio_path: str) -> Union[str, Dict]:
"""
Транскрибация аудиофайла.
Args:
audio_path: Путь к обработанному аудиофайлу.
Returns:
В зависимости от параметра return_timestamps:
- Если return_timestamps=False: строка с распознанным текстом
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
"""
logger.info(f"Начало транскрибации файла: {audio_path}")
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]:
"""
Полный процесс обработки и транскрибации аудиофайла.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
В зависимости от параметра return_timestamps:
- Если return_timestamps=False: строка с распознанным текстом
- Если return_timestamps=True: словарь с ключами "segments" и "text"
"""
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:
# Очистка временных файлов
temp_file_manager.cleanup_temp_files(temp_files)
@@ -0,0 +1,152 @@
"""
Модуль transcriber_service.py содержит класс TranscriptionService,
который отвечает за обработку и транскрибацию аудиофайлов.
"""
import os
import uuid
import tempfile
import time
import traceback
from typing import Dict, Tuple
from .utils import logger
from .audio_utils import AudioUtils
from .history_logger import HistoryLogger
from .audio_sources import AudioSource
from .validators import FileValidator, ValidationError
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)
# Метод get_audio_duration удален, так как его функциональность перенесена в AudioUtils
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 .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
+48
View File
@@ -0,0 +1,48 @@
"""
Модуль utils.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
+183
View File
@@ -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