From 8dab730d4ad6a7468eaf3a4b8d2c623424d36c0b Mon Sep 17 00:00:00 2001 From: Serge Zaigraeff Date: Tue, 4 Mar 2025 09:55:47 +0300 Subject: [PATCH] add transcription history logging --- .gitignore | 2 + app/history_logger.py | 85 ++++++ app/routes.py | 204 +++---------- app/transcriber.py | 2 +- app/transcriber_service.py | 146 +++++++++ client.html | 612 +++++++++++++++++++++++++++++++++++++ config.json | 1 + server.sh | 2 +- 8 files changed, 887 insertions(+), 167 deletions(-) create mode 100644 app/history_logger.py create mode 100644 app/transcriber_service.py create mode 100644 client.html diff --git a/.gitignore b/.gitignore index 6a4dae1..ecd9a65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store __pycache__ +models +logs \ No newline at end of file diff --git a/app/history_logger.py b/app/history_logger.py new file mode 100644 index 0000000..ceb0984 --- /dev/null +++ b/app/history_logger.py @@ -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 diff --git a/app/routes.py b/app/routes.py index 8e70b80..a21b4cf 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,158 +1,28 @@ """ -Модуль routes.py содержит классы для обработки транскрибации аудиофайлов -и регистрации маршрутов API для сервиса распознавания речи. +Модуль routes.py содержит классы для регистрации маршрутов API +для сервиса распознавания речи. """ import os -import uuid -import tempfile -import time -import librosa from flask import request, jsonify -from typing import Dict, Tuple +from typing import Dict -from .utils import logger +from .transcriber_service import TranscriptionService from .audio_sources import ( - AudioSource, - UploadedFileSource, - URLSource, - Base64Source, + UploadedFileSource, + URLSource, + Base64Source, LocalFileSource ) -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 - - 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 jsonify({"error": error}), 400 - - if not file: - return jsonify({"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"]) - } - - return jsonify(response), 200 - - except Exception as e: - logger.error(f"Ошибка при транскрибации: {e}") - return jsonify({"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) - - class Routes: """Класс для регистрации всех эндпоинтов API.""" - + def __init__(self, app, transcriber, config: Dict): """ Инициализация маршрутов. - + Args: app: Flask-приложение. transcriber: Экземпляр транскрайбера. @@ -161,13 +31,13 @@ class Routes: self.app = app self.config = config self.transcription_service = TranscriptionService(transcriber, config) - + # Регистрация маршрутов self._register_routes() - + def _register_routes(self): """Регистрация всех эндпоинтов.""" - + @self.app.route('/health', methods=['GET']) def health_check(): """Эндпоинт для проверки статуса сервиса.""" @@ -175,25 +45,25 @@ class Routes: "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"] source = LocalFileSource(file_path, self.config.get("max_file_size", 100)) - - return self.transcription_service.transcribe_from_source(source, data) - + 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(): """Эндпоинт для получения списка доступных моделей.""" @@ -208,7 +78,7 @@ class Routes: ], "object": "list" }), 200 - + @self.app.route('/v1/models/', methods=['GET']) def retrieve_model(model_id): """Эндпоинт для получения информации о конкретной модели.""" @@ -224,51 +94,55 @@ class Routes: "error": "Model not found", "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)) - return self.transcription_service.transcribe_from_source(source, request.form) - + response, status_code = self.transcription_service.transcribe_from_source(source, request.form) + return jsonify(response), status_code + @self.app.route('/v1/audio/transcriptions/url', methods=['POST']) 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("max_file_size", 100)) - return self.transcription_service.transcribe_from_source(source, params) - + response, status_code = self.transcription_service.transcribe_from_source(source, params) + return jsonify(response), status_code + @self.app.route('/v1/audio/transcriptions/base64', methods=['POST']) 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("max_file_size", 100)) - return self.transcription_service.transcribe_from_source(source, params) - + response, status_code = self.transcription_service.transcribe_from_source(source, params) + return jsonify(response), status_code + @self.app.route('/v1/audio/transcriptions/multipart', methods=['POST']) def transcribe_multipart(): """Эндпоинт для транскрибации аудиофайла, загруженного через форму.""" source = UploadedFileSource(request.files, self.config.get("max_file_size", 100)) - return self.transcription_service.transcribe_from_source(source, request.form) + response, status_code = self.transcription_service.transcribe_from_source(source, request.form) + return jsonify(response), status_code diff --git a/app/transcriber.py b/app/transcriber.py index 4d2d0e8..709d980 100644 --- a/app/transcriber.py +++ b/app/transcriber.py @@ -235,4 +235,4 @@ class WhisperTranscriber: finally: # Очистка временных файлов - self.audio_processor.cleanup_temp_files(temp_files) \ No newline at end of file + self.audio_processor.cleanup_temp_files(temp_files) diff --git a/app/transcriber_service.py b/app/transcriber_service.py new file mode 100644 index 0000000..7042505 --- /dev/null +++ b/app/transcriber_service.py @@ -0,0 +1,146 @@ +""" +Модуль 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) diff --git a/client.html b/client.html new file mode 100644 index 0000000..4f5ad36 --- /dev/null +++ b/client.html @@ -0,0 +1,612 @@ + + + + + + Whisper Speech Recognition + + + +
+

Whisper Speech Recognition

+
+ +
+
+
+
🎤
+
+ Перетащите аудиофайл или выберите файл для транскрибации +
+
+ Поддерживаемые форматы: MP3, WAV, M4A, FLAC, OGG +
+ +
+ +
+
+
+
+ + + +
+ +
+
+

Обрабатываем ваш аудиофайл...

+
+
+ +
+
+
Результаты транскрибации
+ +
+ +
+
+
Длительность аудио
+
+
+
+
Время обработки
+
+
+
+
Модель
+
+
+
+ +
+
+ +
+
+
Журнал операций
+
+
+
+
+ + + + diff --git a/config.json b/config.json index d2458aa..e3db5a2 100644 --- a/config.json +++ b/config.json @@ -2,6 +2,7 @@ "service_port": 5042, "model_path": "/mnt/cloud/llm/whisper/whisper-large-v3-russian", "language": "russian", + "enable_history": true, "chunk_length_s": 30, "batch_size": 16, "max_new_tokens": 256, diff --git a/server.sh b/server.sh index 1cb1f95..5da2040 100755 --- a/server.sh +++ b/server.sh @@ -1,7 +1,7 @@ #!/bin/bash # Имя окружения conda -CONDA_ENV_NAME="transcribe" +CONDA_ENV_NAME="whisper-api-server" # Флаг обновления (по умолчанию false) UPDATE_ENV=false