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
View File
+242
View File
@@ -0,0 +1,242 @@
"""
Модуль processor.py содержит класс AudioProcessor, предназначенный для предобработки аудиофайлов
перед их использованием в системах распознавания речи. Класс предоставляет методы для конвертации
аудио в формат WAV с частотой дискретизации 16 кГц, нормализации уровня громкости,
добавления тишины в начало записи, а также для удаления временных файлов, созданных в процессе обработки.
"""
import os
import subprocess
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')
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
+312
View File
@@ -0,0 +1,312 @@
"""
Модуль sources.py содержит абстрактный класс AudioSource и его конкретные реализации
для обработки различных источников аудиофайлов (загруженные файлы, URL, base64, локальные файлы).
"""
import os
import uuid
import tempfile
import base64
import requests
import abc
from typing import Dict, Tuple, Optional, BinaryIO
import logging
logger = logging.getLogger('app.audio_sources')
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 @@
"""
Модуль 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}")