first commit

This commit is contained in:
Serge Zaigraeff
2025-03-01 19:24:03 +03:00
commit 3f8b28730f
8 changed files with 1215 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Whisper Speech-to-Text API Service
## Overview
This project is a local API server compatible with OpenAI's API for transcribing audio to text using the Whisper model. It's designed to run as a system service, loading the Whisper model into memory at startup and handling transcription requests via REST API.
## Features
- **Audio Transcription**: Supports various input methods:
- Local server files
- Files accessible via URL
- Base64-encoded files
- Multipart form data
- **OpenAI API Compatibility**: Works with `/v1/audio/transcriptions` and `/v1/models` endpoints.
- **Audio Preprocessing**: Converts audio to WAV, normalizes, and adds silence.
- **Hardware Support**: Utilizes GPU (CUDA, MPS) or CPU.
- **Logging**: Tracks all operations.
- **Health Check**: Includes a health check endpoint.
## Quick Start
1. **Edit the Configuration File (`config.json`)**:
- Set the path to your Whisper model (`model_path`).
- Configure other parameters like language (`language`), chunk size (`chunk_length_s`), batch size (`batch_size`), and audio normalization settings.
```json
{
"service_port": 5042,
"model_path": "/path/to/your/whisper-model",
"language": "english",
"chunk_length_s": 30,
"batch_size": 16,
"max_new_tokens": 256,
"return_timestamps": false,
"norm_level": "-0.5",
"compand_params": "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2"
}
```
2. **Run the Server**:
- Simply execute the `server.sh` script:
```bash
./server.sh
```
- If you need to update the environment, use:
```bash
./server.sh --update
```
3. **Use the API**:
- Once the server is running, you can send transcription requests.
- Example request (curl):
```bash
curl -X POST -F file=@audio.wav http://localhost:5042/v1/audio/transcriptions
```
Enjoy seamless audio-to-text transcription with your local Whisper API server!
+136
View File
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Клиент для взаимодействия с сервисом распознавания речи через REST API.
"""
import argparse
import json
import os
import requests
import sys
class TranscribeClient:
"""
Класс для взаимодействия с сервисом распознавания речи через REST API.
"""
def __init__(self, server_url="http://localhost:5000"):
"""
Инициализация клиента.
Args:
server_url: URL сервера распознавания.
"""
self.server_url = server_url
def health(self):
"""
Проверка статуса сервиса.
Returns:
Словарь с информацией о статусе сервиса или None при ошибке.
"""
endpoint = f"{self.server_url}/health"
try:
response = requests.get(endpoint, timeout=10)
if response.status_code == 200:
return response.json()
else:
print(f"Ошибка health check: {response.status_code}")
return None
except requests.exceptions.RequestException as e:
print(f"Ошибка при выполнении health check: {e}")
return None
def transcribe(self, file_path):
"""
Отправка файла на транскрибацию.
Args:
file_path: Путь к аудиофайлу.
Returns:
Словарь с результатом транскрибации или None при ошибке.
"""
# Проверка существования файла
if not os.path.exists(file_path):
print(f"Ошибка: Файл '{file_path}' не найден")
return None
# Формирование абсолютного пути к файлу
absolute_path = os.path.abspath(file_path)
# Подготовка данных для запроса
payload = {"file_path": absolute_path}
# Запрос на транскрибацию
endpoint = f"{self.server_url}/local/transcriptions"
try:
# Отправка запроса
response = requests.post(endpoint, json=payload, timeout=600) # Таймаут 10 минут
# Обработка ответа
if response.status_code == 200:
result = response.json()
return result
else:
print(f"Ошибка запроса: {response.status_code}")
if response.headers.get('content-type') == 'application/json':
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
else:
print(response.text)
return None
except requests.exceptions.RequestException as e:
print(f"Ошибка при отправке запроса: {e}")
return None
def main():
"""Основная функция клиента."""
parser = argparse.ArgumentParser(description="Клиент для сервиса распознавания речи")
parser.add_argument("file_path", help="Путь к аудиофайлу для транскрибации")
parser.add_argument("--server", help="URL сервера распознавания", default="http://localhost:5042")
args = parser.parse_args()
# Создание экземпляра клиента
client = TranscribeClient(args.server)
# Запуск транскрибации
result = client.transcribe(args.file_path)
if result:
print(json.dumps(result, indent=2, ensure_ascii=False))
return result
else:
print("Что-то пошло не так 🤔")
return None
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
{
"service_port": 5042,
"model_path": "/mnt/cloud/llm/tools/whisper-large-v3-russian",
"language": "russian",
"chunk_length_s": 30,
"batch_size": 16,
"max_new_tokens": 256,
"return_timestamps": false,
"norm_level": "-0.5",
"compand_params": "0.3,1 -90,-90,-70,-70,-60,-20,0,0 -5 0 0.2"
}
+13
View File
@@ -0,0 +1,13 @@
# Conda environment dependencies
# Linux FA2 from https://github.com/Dao-AILab/flash-attention/releases
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'
flash_attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp310-cp310-linux_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.10'
Flask==3.1
waitress
librosa
transformers==4.49
accelerate==1.4
+354
View File
@@ -0,0 +1,354 @@
# routes.py
import os
import uuid
import tempfile
import base64
import requests
import time
from flask import request, jsonify
from typing import Dict
import logging
logger = logging.getLogger("routes")
class FakeFile:
"""Имитирует файловый объект для унификации обработки из разных источников.
Позволяет обрабатывать файлы из различных источников (локальный путь, URL, base64)
как стандартные файловые объекты Flask, обеспечивая совместимость с существующей
логикой обработки файлов.
Атрибуты:
- file: Исходный файловый объект или поток
- filename: Имя файла для метаданных
Методы эмулируют поведение стандартного файлового объекта:
- read(): Чтение содержимого файла
- seek(): Перемещение позиции чтения
- tell(): Текущая позиция чтения
- name (property): Возвращает имя файла
Пример использования:
>>> with open('audio.wav', 'rb') as f:
>>> fake = FakeFile(f, 'audio.wav')
>>> fake.save('/tmp/copy.wav') # Новый метод сохранения
>>> processor.handle_file(fake)
"""
def __init__(self, file, filename):
self.file = file
self.filename = filename
def read(self):
return self.file.read()
def seek(self, offset, whence=0):
self.file.seek(offset, whence)
def tell(self):
return self.file.tell()
def save(self, destination):
"""Сохраняет содержимое файла в указанное место назначения.
Args:
destination (str): Путь для сохранения файла
Реализует совместимость с Flask FileStorage API. После записи
сбрасывает позицию чтения в начало файла для последующих операций.
"""
with open(destination, 'wb') as f:
content = self.file.read()
f.write(content)
self.file.seek(0) # Reset pointer after reading
@property
def name(self):
return self.filename
class Routes:
"""Класс для регистрации всех эндпоинтов API.
Этот класс содержит все маршруты (endpoints) для взаимодействия с сервером транскрибации.
Он предоставляет функциональность для получения списка доступных моделей, информации о конкретной модели,
а также для транскрибации аудиофайлов, загруженных различными способами.
Эндпоинты:
- GET /v1/models:
Возвращает JSON-список доступных моделей для транскрибации. Каждая модель содержит информацию об ID,
типе объекта, владельце и разрешениях.
- GET /v1/models/<model_id>:
Возвращает JSON-объект с информацией о конкретной модели, идентифицированной по <model_id>.
Если модель не найдена, возвращает ошибку 404.
- POST /v1/audio/transcriptions:
Транскрибирует аудиофайл, загруженный через форму. Ожидает, что файл будет передан в поле 'file'
multipart формы. Возвращает JSON с транскрибированным текстом и временем обработки.
Поддерживает параметры language, temperature и prompt, передаваемые также через форму.
- POST /v1/audio/transcriptions/url:
Транскрибирует аудиофайл, доступный по указанному URL. Ожидает JSON-запрос с полем 'url',
содержащим URL аудиофайла. Возвращает JSON с транскрибированным текстом и временем обработки.
- POST /v1/audio/transcriptions/base64:
Транскрибирует аудиофайл, закодированный в base64. Ожидает JSON-запрос с полем 'file',
содержащим base64-encoded представление аудиофайла. Возвращает JSON с транскрибированным текстом
и временем обработки.
- POST /v1/audio/transcriptions/multipart:
Аналогичен /v1/audio/transcriptions, но явно указывает на то, что файл ожидается в multipart форме.
Используется для транскрибации аудиофайла, загруженного через multipart-форму.
Возвращает JSON с транскрибированным текстом и временем обработки.
Поддерживает параметры language, temperature и prompt, передаваемые также через форму.
"""
def __init__(self, app, transcriber, config: Dict):
"""
Инициализация маршрутов.
Args:
app: Flask-приложение.
transcriber: Экземпляр транскрайбера.
config: Словарь с конфигурацией.
"""
self.app = app
self.transcriber = transcriber
self.config = config
self.max_file_size_mb = self.config.get("max_file_size", 100) # Default 100MB
# Регистрация маршрутов
self._register_routes()
def _process_audio_file(self, file, request_form=None):
"""
Общая функция для обработки аудиофайла.
Args:
file: Объект файла, полученный из запроса.
request_form: Объект request.form, если есть параметры из формы.
Returns:
jsonify: JSON-ответ с результатом транскрибации.
"""
if not file:
return jsonify({"error": "No file part"}), 400
if file.filename == '':
return jsonify({"error": "No selected file"}), 400
# Проверка размера файла
file.seek(0, os.SEEK_END)
file_length = file.tell()
file.seek(0) # Reset file pointer after reading for size check
if file_length > self.max_file_size_mb * 1024 * 1024:
return jsonify({"error": f"File exceeds maximum size of {self.max_file_size_mb}MB"}), 413
# Извлекаем параметры из запроса, если они есть
language = request_form.get('language', self.config.get('language', 'en')) if request_form else self.config.get('language', 'en') # Default language
temperature = float(request_form.get('temperature', 0.0)) if request_form else 0.0 # Default temperature
prompt = request_form.get('prompt', '') if request_form else '' # Default prompt
# Сохраняем файл во временный файл
temp_dir = tempfile.mkdtemp()
temp_file_path = os.path.join(temp_dir, str(uuid.uuid4()) + "_" + os.path.basename(file.filename))
file.save(temp_file_path)
try:
start_time = time.time()
text = self.transcriber.process_file(temp_file_path)
processing_time = time.time() - start_time
# Форматируем ответ в стиле OpenAI
return jsonify({
"text": text,
"processing_time": processing_time,
"response_size_bytes": len(text.encode('utf-8'))
}), 200
except Exception as e:
logger.error(f"Ошибка при транскрибации: {e}")
return jsonify({"error": str(e)}), 500
finally:
# Очистка временных файлов
os.remove(temp_file_path)
os.rmdir(temp_dir)
def _register_routes(self):
"""Регистрация всех эндпоинтов."""
@self.app.route('/health', methods=['GET'])
def health_check():
"""Эндпоинт для проверки статуса сервиса и получения конфигурации."""
return jsonify({
"status": "ok",
"config": 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"]
if not os.path.exists(file_path):
return jsonify({"error": "File not found"}), 400
try:
with open(file_path, 'rb') as f:
# Создаем объект файла, совместимый с обработчиком
fake_file = FakeFile(f, os.path.basename(file_path))
return self._process_audio_file(fake_file)
except Exception as e:
logger.error(f"Ошибка локальной транскрибации: {e}")
return jsonify({
"error": "Processing error",
"details": str(e)
}), 500
@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
@self.app.route('/v1/audio/transcriptions', methods=['POST'])
def openai_transcribe_endpoint():
"""Эндпоинт для транскрибации аудиофайла."""
if 'file' not in request.files:
return jsonify({"error": "No file part"}), 400
file = request.files['file']
return self._process_audio_file(file, request.form)
@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"]
try:
# Скачиваем файл по URL
response = requests.get(url, stream=True)
response.raise_for_status()
# Сохраняем файл во временный файл
temp_dir = tempfile.mkdtemp()
temp_file_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
with open(temp_file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
# Открываем файл для обработки
with open(temp_file_path, 'rb') as file:
# Создаем объект файла, как будто он пришел из request.files
fake_file = FakeFile(file, os.path.basename(temp_file_path))
result = self._process_audio_file(fake_file)
return result
except Exception as e:
logger.error(f"Ошибка при транскрибации файла по URL {url}: {e}")
return jsonify({
"error": "Transcription error",
"details": str(e)
}), 500
finally:
# Очистка временных файлов
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
if os.path.exists(temp_dir):
os.rmdir(temp_dir)
@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"]
try:
# Декодируем base64
audio_data = base64.b64decode(base64_data)
# Сохраняем файл во временный файл
temp_dir = tempfile.mkdtemp()
temp_file_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
with open(temp_file_path, 'wb') as f:
f.write(audio_data)
# Открываем файл для обработки
with open(temp_file_path, 'rb') as file:
# Создаем объект файла, как будто он пришел из request.files
fake_file = FakeFile(file, os.path.basename(temp_file_path))
result = self._process_audio_file(fake_file)
return result
except Exception as e:
logger.error(f"Ошибка при транскрибации файла из base64: {e}")
return jsonify({
"error": "Transcription error",
"details": str(e)
}), 500
finally:
# Очистка временных файлов
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
if os.path.exists(temp_dir):
os.rmdir(temp_dir)
@self.app.route('/v1/audio/transcriptions/multipart', methods=['POST'])
def transcribe_multipart():
"""Эндпоинт для транскрибации аудиофайла, загруженного через форму."""
if 'file' not in request.files:
return jsonify({"error": "No file part"}), 400
file = request.files['file']
return self._process_audio_file(file, request.form)
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""
Локальный сервис распознавания речи с использованием модели Whisper.
Запускается как системный сервис, загружает модель в память один раз и обрабатывает
запросы через REST API.
"""
import os
import json
import time
import uuid
import tempfile
import logging
import subprocess
import argparse
from typing import Dict, Optional, Union, Tuple
from pathlib import Path
# Flask для REST API
from flask import Flask, request, jsonify
from routes import Routes
import waitress
# Импортируем компоненты из существующего кода
import torch
import librosa
import numpy as np
from transformers import WhisperForConditionalGeneration, WhisperProcessor, pipeline
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler("logs/transcribe.log")
]
)
logger = logging.getLogger("whisper-service")
class AudioProcessor:
"""Класс для предобработки аудиофайлов перед распознаванием."""
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")
def convert_to_wav(self, input_path: str) -> str:
"""
Конвертация входного аудиофайла в WAV формат с частотой дискретизации 16 кГц.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
Путь к сконвертированному WAV-файлу.
"""
# Проверка расширения файла
if input_path.lower().endswith('.wav'):
# Проверяем, нужно ли преобразовывать WAV-файл (например, если частота не 16 кГц)
try:
info = subprocess.check_output(['soxi', input_path]).decode()
if '16000 Hz' in info:
logger.info(f"Файл {input_path} уже в формате WAV с частотой 16 кГц")
return input_path
except subprocess.CalledProcessError:
logger.warning(f"Не удалось получить информацию о WAV-файле {input_path}")
# Продолжаем конвертацию, чтобы быть уверенными в формате
# Создаем временный файл для WAV
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.wav")
# Команда для конвертации
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel", "warning",
"-i", input_path,
"-ar", "16000",
"-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-файлу.
"""
# Создаем временный файл для нормализованного аудио
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_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 add_silence(self, input_path: str) -> str:
"""
Добавляет тишину в начало аудиофайла.
Args:
input_path: Путь к аудиофайлу.
Returns:
Путь к аудиофайлу с добавленной тишиной.
"""
# Создаем временный файл
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}_silence.wav")
# Команда для добавления тишины в начало файла
cmd = [
"sox",
input_path,
output_path,
"pad", "2.0" # 2 секунды тишины в начале
]
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 cleanup_temp_files(self, file_paths: list):
"""
Удаление временных файлов и директорий.
Args:
file_paths: Список путей к временным файлам.
"""
for path in file_paths:
try:
if os.path.exists(path):
os.remove(path)
logger.debug(f"Удален временный файл: {path}")
# Попытка удалить директорию, если она пуста
temp_dir = os.path.dirname(path)
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
os.rmdir(temp_dir)
logger.debug(f"Удалена временная директория: {temp_dir}")
except Exception as e:
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
def process_audio(self, input_path: str) -> Tuple[str, list]:
"""
Полная обработка аудиофайла: конвертация, нормализация и добавление тишины.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
Кортеж: (путь к обработанному файлу, список временных файлов для удаления)
"""
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)
# Добавление тишины
silence_path = self.add_silence(normalized_path)
temp_files.append(silence_path)
return silence_path, temp_files
except Exception as e:
logger.error(f"Ошибка при обработке аудио {input_path}: {e}")
self.cleanup_temp_files(temp_files)
raise
class WhisperTranscriber:
"""Класс для распознавания речи с помощью модели Whisper."""
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.torch_dtype = torch.bfloat16
# Создаем объект для обработки аудио
self.audio_processor = AudioProcessor(config)
# Определяем устройство для вычислений
self.device = self._get_device()
# Загружаем модель при инициализации
self._load_model()
def _get_device(self) -> torch.device:
"""Определение доступного устройства для вычислений."""
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):
"""Загрузка модели и процессора."""
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("Модель успешно загружена и готова к использованию")
def _load_audio(self, file_path: str) -> Tuple[np.ndarray, int]:
"""
Загрузка аудиофайла с использованием librosa.
Args:
file_path: Путь к аудиофайлу.
Returns:
Tuple с массивом numpy и частотой дискретизации.
"""
try:
audio_array, sampling_rate = librosa.load(file_path, sr=16000)
return audio_array, sampling_rate
except Exception as e:
logger.error(f"Ошибка при загрузке аудио {file_path}: {e}")
raise
def transcribe(self, audio_path: str) -> str:
"""
Транскрибация аудиофайла.
Args:
audio_path: Путь к обработанному аудиофайлу.
Returns:
Распознанный текст.
"""
logger.info(f"Начало транскрибации файла: {audio_path}")
# Загрузка аудио в формате numpy array
audio_array, sampling_rate = self._load_audio(audio_path)
# Транскрибация с корректным форматом данных
result = self.asr_pipeline(
{"raw": audio_array, "sampling_rate": sampling_rate},
generate_kwargs={"language": self.language, "max_new_tokens": self.max_new_tokens},
return_timestamps=self.return_timestamps
)
transcribed_text = result.get("text", "")
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
return transcribed_text
def process_file(self, input_path: str) -> str:
"""
Полный процесс обработки и транскрибации аудиофайла.
Args:
input_path: Путь к исходному аудиофайлу.
Returns:
Распознанный текст.
"""
start_time = time.time()
logger.info(f"Начало обработки файла: {input_path}")
temp_files = []
try:
# Обработка аудио (конвертация, нормализация, добавление тишины)
processed_path, temp_files = self.audio_processor.process_audio(input_path)
# Транскрибация
text = self.transcribe(processed_path)
elapsed_time = time.time() - start_time
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
return text
finally:
# Очистка временных файлов
self.audio_processor.cleanup_temp_files(temp_files)
class WhisperServiceAPI:
"""Класс для API сервиса распознавания речи."""
def __init__(self, config_path: str):
"""
Инициализация API сервиса.
Args:
config_path: Путь к конфигурационному файлу.
"""
# Загрузка конфигурации
self.config = self._load_config(config_path)
# Порт для сервиса
self.port = self.config["service_port"]
# Создание экземпляра транскрайбера
self.transcriber = WhisperTranscriber(self.config)
# Создание Flask-приложения
self.app = Flask("whisper-service")
# Регистрация маршрутов
Routes(self.app, self.transcriber, self.config)
# self._register_routes()
logger.info(f"API сервис инициализирован, порт: {self.port}")
def _load_config(self, config_path: str) -> Dict:
"""
Загрузка конфигурации из JSON-файла.
Args:
config_path: Путь к файлу конфигурации.
Returns:
Словарь с параметрами конфигурации.
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл конфигурации содержит некорректный JSON.
"""
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
return config
except FileNotFoundError as e:
logger.error(f"Файл конфигурации не найден: {e}")
raise
except json.JSONDecodeError as e:
logger.error(f"Ошибка при загрузке конфигурации: {e}")
raise
def run(self):
"""Запуск сервиса."""
logger.info(f"Запуск сервиса на порту {self.port}")
# Использовать waitress для production-ready сервера
waitress.serve(self.app, host='0.0.0.0', port=self.port)
def main():
"""Основная функция для запуска сервиса."""
# Парсинг аргументов командной строки
parser = argparse.ArgumentParser(description="Сервис распознавания речи с использованием модели Whisper")
parser.add_argument("--config", help="Путь к файлу конфигурации", default="config.json")
args = parser.parse_args()
# Запуск сервиса
service = WhisperServiceAPI(args.config)
service.run()
if __name__ == "__main__":
main()
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Имя окружения conda
CONDA_ENV_NAME="transcribe"
# Флаг обновления (по умолчанию false)
UPDATE_ENV=false
# Проверка наличия аргумента --update
if [[ "$1" == "--update" ]]; then
UPDATE_ENV=true
fi
# Проверка наличия conda
if ! command -v conda &> /dev/null; then
echo "Conda не установлен. Пожалуйста, установите conda и попробуйте снова."
exit 1
fi
# Если флаг --update установлен, проверяем и обновляем окружение
if [[ "$UPDATE_ENV" == true ]]; then
# Создание окружения conda, если оно не существует
if ! conda env list | grep -q "$CONDA_ENV_NAME"; then
echo "Создание окружения conda: $CONDA_ENV_NAME"
conda create -n "$CONDA_ENV_NAME" python=3.11 -y
else
echo "Окружение conda '$CONDA_ENV_NAME' уже существует."
fi
# Получение пути к conda
CONDA_PATH=$(which conda)
# Проверка, что путь к conda найден
if [ -z "$CONDA_PATH" ]; then
echo "Не удалось найти путь к conda. Убедитесь, что conda установлен и добавлен в PATH."
exit 1
fi
# Активация окружения conda
echo "Активация окружения conda: $CONDA_ENV_NAME"
source $(dirname "$CONDA_PATH")/../etc/profile.d/conda.sh
conda activate "$CONDA_ENV_NAME"
# Установка зависимостей из requirements.txt
if [ -f "requirements.txt" ]; then
echo "Установка зависимостей из requirements.txt"
pip install --no-cache-dir -r requirements.txt
else
echo "Файл requirements.txt не найден. Убедитесь, что он находится в той же директории, что и скрипт."
exit 1
fi
fi
# Запуск сервера
echo "Запуск сервера..."
python server.py --config config.json
echo "Сервер остановлен."