From 3f8b28730fe495e7b61cc0a7f20d314921fb14db Mon Sep 17 00:00:00 2001 From: Serge Zaigraeff Date: Sat, 1 Mar 2025 19:24:03 +0300 Subject: [PATCH] first commit --- README.md | 56 ++++++ client_api.ipynb | 136 ++++++++++++++ client_local.py | 115 ++++++++++++ config.json | 11 ++ requirements.txt | 13 ++ routes.py | 354 +++++++++++++++++++++++++++++++++++ server.py | 472 +++++++++++++++++++++++++++++++++++++++++++++++ server.sh | 58 ++++++ 8 files changed, 1215 insertions(+) create mode 100644 README.md create mode 100644 client_api.ipynb create mode 100644 client_local.py create mode 100644 config.json create mode 100644 requirements.txt create mode 100644 routes.py create mode 100644 server.py create mode 100755 server.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..9eb7949 --- /dev/null +++ b/README.md @@ -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! \ No newline at end of file diff --git a/client_api.ipynb b/client_api.ipynb new file mode 100644 index 0000000..2f08ae3 --- /dev/null +++ b/client_api.ipynb @@ -0,0 +1,136 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "\n", + "def transcribe_audio(file_path: str, server_url: str = \"\"):\n", + " \"\"\"\n", + " Отправляет аудиофайл на сервер для распознавания и возвращает результат.\n", + "\n", + " Args:\n", + " file_path: Путь к локальному аудиофайлу.\n", + " server_url: URL сервера (по умолчанию \"\").\n", + "\n", + " Returns:\n", + " Распознанный текст.\n", + " \"\"\"\n", + " try:\n", + " # Открываем файл для отправки\n", + " with open(file_path, \"rb\") as audio_file:\n", + " # Формируем запрос\n", + " files = {\"file\": (file_path, audio_file)}\n", + " response = requests.post(f\"{server_url}/v1/audio/transcriptions\", files=files)\n", + "\n", + " # Проверяем статус ответа\n", + " if response.status_code == 200:\n", + " return response.json()\n", + " else:\n", + " print(f\"Ошибка: {response.status_code}\")\n", + " print(response.json())\n", + " return None\n", + "\n", + " except Exception as e:\n", + " print(f\"Ошибка при отправке файла: {e}\")\n", + " return None\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'processing_time': 11.976396322250366, 'text': ' Всем привет, это Данила ГД, и сегодня будет выпуск, посвященный математике. Наконец-то, надеюсь, вы давно этого ждали. И так как недавно вышел сериал «Игра в кальмара», я решил разобрать одну из игр. Но это будет не та игра, в которую играют все игроки, которые участвуют, собственно, в этих играх. А это будет обычная русская рулетка, в которую на самом деле играли и в каких-то других произведениях искусства. Итак, в русскую рулетку можно играть по-разному. Именно в этой серии главный герой играл с загадочным человеком из метро, и они играли по таким правилам. Изначально вставляется один патрон, прокручивается, и затем они, уже не трогая барабан, по очереди стреляют в себя. Первый игрок стреляет, потом, если он выжил, стреляет второй, если второй выжил, а то и стреляет первый и так далее. И в итоге стопроцентно на шестой раз, если вдруг оба дожили до конца, то на шестой раз второй игрок точно себя убьет. И что мне показалось интересным. Подумайте, есть ли разница вы начали первым или вы начали вторым. Отличается ли математическое ожидание вашей смерти от того, когда вы сделали первый выстрел? Вы можете пока подумать. Я могу сказать за себя, да, то есть я уже знаю ответ. Но когда я еще не посчитал, я думал, что с одной стороны, тот, кто начинает первым, у него изначально, да, вероятность одна шестая умереть. То есть второй игрок даже не рискует, а первый уже может себя убить. И вроде как это прикольно. Это с одной стороны. Но с другой стороны, и я думаю, что это перевешивает, тот факт, что последний выстрел это 100% вероятность себя убить, вот например предпоследний это всего лишь 50%, а последний сразу 100%. Я думаю, что это слишком весомый аргумент, что вторым начинать менее выгодно. Такая была у меня логика. Надеюсь, я достаточно дал времени подумать, и давайте перейдем к расчетам, наконец, к нашей любимой табличке. Но расчетов будет немного, они будут несложные. Как мы будем считать? Мы возьмем, например, первого игрока и сначала посчитаем вероятность умереть от первого выстрела, затем от третьего выстрела и затем от пятого выстрела, и после мы суммируем все эти три вероятности и узнаем общее математическое ожидание проиграть в этой игре. Давайте начнем. Первый выстрел это одна шестая, да у нас шесть отделений для патронов в барабане и один патрон, то есть шестнадцать процентов, давайте все переведем в проценты и почти семнадцать тоже. Теперь третий выстрел, здесь уже чуть посложнее. Как нам в целом добраться до третьего выстрела? Нам нужно, чтобы несколько условий срослось. Первое. Чтобы наш первый выстрел не закончился смертью. Ведь если мы себя убьем, третьего выстрела уже не будет. Второе. Чтобы выстрел второго игрока не закончился смертью. Давайте сразу это запишем. То есть пять шестых это шанс выжить от первого выстрела. Умножить на четыре пятых — это шанс выжить второго игрока от его второго выстрела. Ну, то есть для него первого, а в целом второго. И далее мы уже вставляем вероятность умереть нашу на третьем выстреле. Так как осталось еще четыре варианта, и среди этих четырех вариантов один патрон, то это, соответственно, одна четвертая. Вот мы получаементов. Да, уже довольно интересно. Можно сделать какие-то выводы, но давайте посчитаем пятый выстрел. Да, это сложно. Пятый выстрел это соответственно то же самое, только мы должны ещё и пережить первый, пережить третий. Второй игрок должен пережить второй выстрел, четвёртый выстрел, и только тогда мы наконец сможем убить себя или пережить пятого выстрела. Это одна вторая. И у нас снова получается 16%. Ну и, разумеется, сумма всего этого дела 50. И вуаля, мы получили расчет, что неважно, начинаете вы первым или вторым. По-хорошему, если вообще все делать по науке, да, и проверять самого себя, то нужно посчитать то же самое для второго игрока. Но я не буду этого делать, потому что здесь получился слишком ровно ответ, но есть более важный пункт. На самом деле, эта задача решается намного проще. Она, по сути, не требует математики. Давайте подойдем к ней логически. У нас есть барабан, в котором есть шесть отделов под патроны. В одном из них лежит патрон. И у нас есть два игрока. Каждый из двух игроков берет себе по три отсека под патроны. Каждый отсек это 16%, независимо ни от чего. То есть в три в сумме это 50%. Получается, что у каждого игрока 50% попасть в патрон или 50% не попасть в патрон. То есть вы уже должны понимать, что вообще не важно, какой порядок выстрелов у вас будет. У вас в любом случае есть три этих отсека, и либо вы сразу выбрали те три отсека, в одном из которых лежит патрон, либо вы выбрали три отсека, в котором нет патронов. То есть вообще не важно, вы можете стрельнуть три раза в соперника, потом три раза в себя, или наоборот, три раза в себя, три раза в соперника, или там два раза в себя, потом три раза будет стрелять соперник, и потом последний в себя. Короче, вообще не важно. И это довольно прикольно. То есть выбор здесь не имеет значения, хотя интуитивно кажется, что офигеть, какое значение он имеет. И если заходить дальше, да, если бы я жил в альтернативном мире, где я не хейтил бы казино, то это довольно неплохая игра для казино, где, например, ну, лежит игрушечный пистолет, есть тоже дилер, и вы можете подойти и сыграть с ним в эту игру. И прикол в том, что если вы просто играете, например, на 100 долларов, то всегда стреляет дилер, то есть он принимает решение. И вы такие, ну а в чем профит казино? Ведь это же около нулевая игра, ты только что это сказал. Да, так и есть. Но можно дать игроку возможность, например, за символические 5 долларов, забрать себе револьвер и принимать решение самому. И в каких-то казино вы могли бы действительно жульничать, но даже если нам все честно, люди все равно будут себя накручивать. И, соответственно, такая игра как раз играла на вот этом желании людей все контролировать, и можно было бы очень много с этого зарабатывать. В целом, вы можете подумать и вставить что-то подобное в какую-то свою игру, сделать мини-игру внутри своей большой игры, как любят какие-нибудь рок-стары. И в целом, на этом я могу закончить, но меня не давало покоя, что как будто бы когда-то должно быть важно, кто начинает первым, а кто вторым. И давайте немножко поменяем правила, потому что это не единственная возможность играть в русскую рулетку, можно делать по-другому. И новые правила я предлагаю сделать такими. У нас снова играют два человека. Первый точно так же вставляет патрон, крутит барабан и стреляет в себя. Если он выживает, то второй открывает барабан, вставляет еще один патрон, крутит барабан и стреляет в себя. Если и он выживает, то опять первый еще один патрон вставляет, крутит барабан и... Вы уже поняли. В итоге тоже шестой выстрел. Это будет стопроцентная смерть, если до нее дойдет. Первая такая же, шестая такая же что я такая же а вот между уже немножко есть разница и давайте опять же вы можете делать предположение опять будет 50 на 50 или все-таки разница будет а я пока подготовлю файл для новых подсчетов теперь то же самое опять у нас одна шестая у первого выстрела но у третьего выстрела уже все немножко по-другому начина же. У нас есть вероятность выжить первого игрока при первом выстреле, затем нам нужно получить вероятность выжить второго игрока при втором выстреле или при первом выстреле этого игрока, но это уже будет не четыре пятых, как в прошлый раз, а это будет четыре шестых, потому что его вероятность умереть была две шестые, то есть у него было два патрона и четыре пустых ячейки. И теперь мы уже вводим вероятность умереть нашу. Она составляет три шестых. Три патрона, шесть всего ячейок барабан. И это двадцать семь процентов. Уже интересно, да? Цифры другие, не шестнадцать процентов. Но, возможно, пятый выстрел сбалансирует и опять будет в сумме пятьдесят процентов. выстрел давайте опять скопирую отсюда начало даже все кстати я могу скопировать все потому что вероятность умереть и вероятность выжить одинаковые три шестых поэтому просто все копируем вставляем теперь мы умножаем на вероятность выжить второго игрока при четвертом выстреле это довольно маленькая вероятность у него четыре патрона соответственно это всего лишь шестых. И потом вероятность умереть при пятом выстреле. Это, как вы понимаете, 5 шестых. И это 7,72%. В целом, на глаз выглядит так, что в слуе будет 50%, но давайте проверим. Сумма 52,16. Вот это довольно любопытно. Кажется, что что-то не так. Да, если в данном случае я вам говорил, что так все гладенько получилось, и второго игрока в принципе можно не считать, то здесь явно придется посчитать. И, наверное, вы уже все поняли, и я просто ускорю видео и быстро посчитаю все для второго игрока, вы поверите мне на слово. Сто процентов. Все сошлось, это не ошибка, и действительно, вероятность проиграть первого игрока выше, чем вероятность проиграть второго игрока, вы поверите мне на слово, 100%. Все сошлось, это не ошибка, и действительно, вероятность проиграть первого игрока выше, чем вероятность проиграть второго игрока, а именно на 4,5% примерно, и это довольно прикольно, на мой взгляд. Опять же, если возвращаться к казино или подобным играм, то тут даже не надо вообще ничего придумывать, вы просто делаете такую игру и даете игрокам играть с дилером, но просто игрок обязан первый выстрел сделать в себя. Опять же, это может быть какая-то механика в играх с боксами и так далее. В общем, вот вам математика русской рулетки. Вроде ничего сложного, но довольно любопытные два примера, которые можно по-разному использовать, по-разному интерпретировать. На этом всё. Всем спасибо за внимание. До новых встреч.'}\n" + ] + } + ], + "source": [ + "# Запуск транскрибации\n", + "server=\"https://box.nnp.space/api/transcribe/A88tc0ZaQO8-JOOiI7pmYM8-Hu60WQAPgMQ-RF_-zz1UBCk\"\n", + "file=\"/Users/serge/Downloads/emToQc8pxaI_audio.mp3\"\n", + "text = transcribe_audio(file, server)\n", + "\n", + "print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "zn4_Ngquuq8-Z9eFld-mYDo-df4uwUaFwdE-cbuWI7thgdg\n", + "Token copied to clipboard!\n" + ] + } + ], + "source": [ + "import secrets\n", + "import pyperclip # Import the pyperclip library\n", + "\n", + "def generate_token(num_groups: int, group_length: int) -> str:\n", + " \"\"\"\n", + " Generates a token with a specified number of groups and length for each group.\n", + "\n", + " Args:\n", + " num_groups (int): The number of groups in the token.\n", + " group_length (int): The length of each group.\n", + "\n", + " Returns:\n", + " str: The generated token.\n", + " \"\"\"\n", + " groups = [secrets.token_urlsafe(group_length) for _ in range(num_groups)]\n", + " return \"-\".join(groups)\n", + "\n", + "if __name__ == \"__main__\":\n", + " num_groups = 4\n", + " group_length = 8\n", + " token = generate_token(num_groups, group_length)\n", + " print(token)\n", + "\n", + " # Copy the token to the clipboard\n", + " pyperclip.copy(token)\n", + " print(\"Token copied to clipboard!\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "wpc", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/client_local.py b/client_local.py new file mode 100644 index 0000000..fa449fc --- /dev/null +++ b/client_local.py @@ -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() diff --git a/config.json b/config.json new file mode 100644 index 0000000..a3df9a0 --- /dev/null +++ b/config.json @@ -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" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ceac8a --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..c7ebbc2 --- /dev/null +++ b/routes.py @@ -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/: + Возвращает JSON-объект с информацией о конкретной модели, идентифицированной по . + Если модель не найдена, возвращает ошибку 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/', 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) \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..67217b4 --- /dev/null +++ b/server.py @@ -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() diff --git a/server.sh b/server.sh new file mode 100755 index 0000000..b648cb2 --- /dev/null +++ b/server.sh @@ -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 "Сервер остановлен." \ No newline at end of file