This commit is contained in:
2026-02-25 23:48:01 +03:00
commit 6c4cf7ba2f
9 changed files with 894 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Настройки Whisper модели
MODEL_SIZE=base
DEVICE=cuda
# ROCm настройки (AMD GPU)
# Версия GFX для обхода совместимости (10.3.0 = RDNA2/RX 6000 series)
HSA_OVERRIDE_GFX_VERSION=10.3.0
# Индекс GPU (обычно 0)
ROCM_VISIBLE_DEVICES=0
# URL API транскрибации (для UI)
WHISPER_API_URL=http://whisper-api:8080/transcribe
# Время хранения задач в часах
DB_RETENTION_HOURS=6

118
README.md Normal file
View File

@@ -0,0 +1,118 @@
# Whisper Speed Transcribe
Полнофункциональный Web UI для транскрибации аудио/видео с использованием Whisper.
## Возможности
- 📁 Загрузка видео (MP4, MKV, AVI, MOV, WebM) и аудио (MP3, WAV, M4A, FLAC, OGG)
- ⚡ Изменение скорости аудио (0.5x - 2.0x) без изменения высоты тона
- 🎙️ Транскрибация или перевод на английский язык
- 🔊 Прослушивание обработанного аудио после конвертации
- 📋 История задач с автоудалением через 6 часов
- 🐳 Полная поддержка Docker и Docker Compose
## Быстрый старт
### Требования
- Docker
- Docker Compose
### Запуск
1. Клонируйте репозиторий и перейдите в директорию проекта:
```bash
cd whisper-webui
```
2. Создайте файл `.env` на основе `.env.example`:
```bash
cp .env.example .env
```
3. Запустите все сервисы:
```bash
docker-compose up -d
```
4. Откройте в браузере:
- **Gradio UI**: http://localhost:7860
- **API**: http://localhost:8080
## Использование
### Через Web UI
1. Загрузите видео или аудио файл
2. Настройте скорость воспроизведения (по умолчанию 1.25x)
3. Выберите задачу: транскрибация или перевод
4. При необходимости укажите язык
5. Нажмите "Обработать файл"
6. Прослушайте обработанное аудио и скопируйте текст
### Через curl (прямой вызов API)
```bash
# Транскрибация
curl -X POST -F "file=@test.m4a" http://localhost:8080/transcribe
# Транскрибация с указанием языка
curl -X POST -F "file=@test.m4a" -F "language=ru" http://localhost:8080/transcribe
# Перевод на английский
curl -X POST -F "file=@test.m4a" -F "task=translate" http://localhost:8080/transcribe
```
## Настройки
### Переменные окружения
| Переменная | По умолчанию | Описание |
|------------|--------------|----------|
| `MODEL_SIZE` | `base` | Размер модели Whisper (tiny, base, small, medium, large) |
| `DEVICE` | `cpu` | Устройство для инференса (cpu, cuda) |
| `WHISPER_API_URL` | `http://whisper-api:8080/transcribe` | URL API транскрибации |
| `DB_RETENTION_HOURS` | `6` | Время хранения задач в часах |
### Доступные модели Whisper
| Модель | Размер | Скорость | Качество |
|--------|--------|----------|----------|
| tiny | ~39 MB | Очень быстро | Низкое |
| base | ~74 MB | Быстро | Среднее |
| small | ~244 MB | Средне | Хорошее |
| medium | ~769 MB | Медленно | Очень хорошее |
| large | ~1550 MB | Медленно | Лучшее |
## Структура проекта
```
whisper-webui/
├── docker-compose.yml # Конфигурация Docker Compose
├── .env.example # Пример переменных окружения
├── README.md # Документация
├── api/ # API сервис (Whisper)
│ ├── Dockerfile
│ ├── main.py
│ └── requirements.txt
└── ui/ # Gradio интерфейс
├── Dockerfile
├── app.py
└── requirements.txt
```
## Остановка
```bash
docker-compose down
```
## Примечание
- Аудио файлы не сохраняются - только метаданные и результаты транскрибации
- База данных SQLite автоматически очищает записи старше 6 часов
- Для GPU ускорения установите `DEVICE=cuda` и используйте соответствующий образ
## Лицензия
MIT

32
api/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ROCm-совместимый образ с PyTorch
FROM rocm/pytorch:rocm5.7.3_ubuntu22.04_py3.10_pytorch2.1.2
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
ffmpeg \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Создание рабочей директории
WORKDIR /app
# Установка переменных окружения для Python
ENV PYTHONUNBUFFERED=1
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
# Установка Python зависимостей (без torch/torchaudio - уже в базовом образе)
RUN pip install --no-cache-dir \
fastapi==0.109.0 \
uvicorn[standard]==0.27.0 \
python-multipart==0.0.6 \
openai-whisper==20231117
# Копирование исходного кода
COPY main.py .
# Экспорт порта
EXPOSE 8080
# Запуск приложения
CMD ["python", "main.py"]

148
api/main.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Whisper API - FastAPI сервис для транскрибации аудио
Поддержка ROCm (AMD GPU)
Эндпоинт: POST /transcribe
"""
import os
import tempfile
import whisper
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
import torch
app = FastAPI(title="Whisper Transcription API")
# Загрузка модели при старте
MODEL_SIZE = os.getenv("MODEL_SIZE", "base")
DEVICE_ENV = os.getenv("DEVICE", "cuda")
# Определение устройства для инференса
# PyTorch на ROCm определяет AMD GPU как CUDA-совместимое устройство
def get_device():
"""Определение доступного устройства для инференса"""
if DEVICE_ENV == "cuda" and torch.cuda.is_available():
device_name = torch.cuda.get_device_name(0)
print(f"Используем GPU: {device_name}")
return "cuda"
else:
print("Используем CPU")
return "cpu"
DEVICE = get_device()
print(f"Загрузка модели Whisper {MODEL_SIZE} на устройство: {DEVICE}")
# Информация о GPU
if torch.cuda.is_available():
gpu_name = torch.cuda.get_device_name(0)
gpu_memory = torch.cuda.get_device_properties(0).total_memory / (1024**3)
print(f"GPU: {gpu_name}, память: {gpu_memory:.1f} GB")
try:
model = whisper.load_model(MODEL_SIZE, device=DEVICE)
print("Модель успешно загружена")
except Exception as e:
print(f"Ошибка загрузки модели: {e}")
model = None
@app.get("/health")
async def health_check():
"""Проверка здоровья сервиса"""
gpu_info = None
if torch.cuda.is_available():
gpu_info = {
"name": torch.cuda.get_device_name(0),
"available": True
}
return {
"status": "healthy",
"model_loaded": model is not None,
"device": DEVICE,
"gpu": gpu_info
}
@app.get("/")
async def root():
"""Корневой эндпоинт с информацией об API"""
return {
"service": "Whisper API (ROCm/AMD GPU)",
"model": MODEL_SIZE,
"device": DEVICE,
"gpu_available": torch.cuda.is_available(),
"endpoints": {
"transcribe": "POST /transcribe",
"health": "GET /health"
}
}
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
task: str = Form("transcribe"),
language: str = Form(None)
):
"""
Транскрибация или перевод аудио файла
Параметры:
- file: аудио/видео файл
- task: "transcribe" или "translate"
- language: код языка (опционально, например "ru", "en")
Пример curl:
curl -X POST -F "file=@test.m4a" -F "task=transcribe" http://localhost:8080/transcribe
"""
if model is None:
raise HTTPException(status_code=503, detail="Модель Whisper не загружена")
# Проверка типа задачи
if task not in ["transcribe", "translate"]:
raise HTTPException(status_code=400, detail="Недопустимое значение task. Используйте 'transcribe' или 'translate'")
# Сохранение загруженного файла во временный файл
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file.filename)[1]) as tmp_input:
content = await file.read()
tmp_input.write(content)
tmp_input_path = tmp_input.name
try:
# Параметры транскрибации
transcribe_options = {
"task": task,
"verbose": False,
}
# Добавляем язык если указан
if language:
transcribe_options["language"] = language
# Выполнение транскрибации
result = model.transcribe(tmp_input_path, **transcribe_options)
# Возвращаем результат
return JSONResponse(content={
"text": result["text"],
"segments": result.get("segments", []),
"language": result.get("language", language),
"task": task,
"filename": file.filename,
"device_used": DEVICE
})
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка при транскрибации: {str(e)}")
finally:
# Удаление временного файла
try:
os.unlink(tmp_input_path)
except:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

6
api/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
openai-whisper==20231117
torch==2.1.2
torchaudio==2.1.2

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
whisper-api:
build:
context: ./api
dockerfile: Dockerfile
container_name: whisper-api
ports:
- "8080:8080"
environment:
- MODEL_SIZE=${MODEL_SIZE:-turbo}
- DEVICE=${DEVICE:-cuda}
- HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-10.3.0}
- ROCM_VISIBLE_DEVICES=${ROCM_VISIBLE_DEVICES:-0}
volumes:
- whisper-cache:/root/.cache/whisper
devices:
- /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri
# Настройки для GPU
shm_size: '10gb'
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
group_add:
- video
- render
security_opt:
- seccomp:unconfined
gradio-ui:
build:
context: ./ui
dockerfile: Dockerfile
container_name: gradio-ui
ports:
- "7860:7860"
environment:
- WHISPER_API_URL=${WHISPER_API_URL:-http://whisper-api:8080/transcribe}
- DB_RETENTION_HOURS=${DB_RETENTION_HOURS:-6}
volumes:
- ./ui/db:/app/db
depends_on:
whisper-api:
condition: service_healthy
restart: always
volumes:
whisper-cache:

27
ui/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM rocm/pytorch:latest
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Создание рабочей директории
WORKDIR /app
# Создание директории для базы данных
RUN mkdir -p /app/db
# Копирование зависимостей
COPY requirements.txt .
# Установка Python зависимостей
RUN pip install --no-cache-dir -r requirements.txt
# Копирование исходного кода
COPY app.py .
# Экспорт порта
EXPOSE 7860
# Запуск приложения
CMD ["python", "app.py"]

494
ui/app.py Normal file
View File

@@ -0,0 +1,494 @@
"""
Gradio Web UI для Whisper транскрибации
- Загрузка видео/аудио файлов
- Обработка FFmpeg (извлечение аудио, изменение скорости)
- Вызов API транскрибации
- Сохранение результатов в SQLite с автоудалением через 6 часов
"""
import os
import sqlite3
import uuid
import tempfile
import subprocess
import requests
import gradio as gr
from datetime import datetime, timedelta
import json
# Конфигурация из переменных окружения
WHISPER_API_URL = os.getenv("WHISPER_API_URL", "http://whisper-api:8080/transcribe")
DB_RETENTION_HOURS = int(os.getenv("DB_RETENTION_HOURS", "6"))
DB_PATH = "/app/db/jobs.db"
# Поддерживаемые языки
LANGUAGES = [
"Автоопределение", "Английский", "Русский", "Испанский", "Французский",
"Немецкий", "Итальянский", "Португальский", "Польский", "Турецкий",
"Нидерландский", "Японский", "Корейский", "Китайский"
]
LANGUAGE_CODES = {
"Автоопределение": None,
"Английский": "en",
"Русский": "ru",
"Испанский": "es",
"Французский": "fr",
"Немецкий": "de",
"Итальянский": "it",
"Португальский": "pt",
"Польский": "pl",
"Турецкий": "tr",
"Нидерландский": "nl",
"Японский": "ja",
"Корейский": "ko",
"Китайский": "zh"
}
def init_database():
"""Инициализация SQLite базы данных"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
filename TEXT NOT NULL,
task_type TEXT NOT NULL,
language TEXT,
speed REAL NOT NULL,
transcript_text TEXT NOT NULL,
api_url TEXT NOT NULL
)
''')
conn.commit()
conn.close()
# Удаление старых записей при запуске
cleanup_old_jobs()
def cleanup_old_jobs():
"""Удаление записей старше DB_RETENTION_HOURS часов"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cutoff_time = datetime.now() - timedelta(hours=DB_RETENTION_HOURS)
cursor.execute(
"DELETE FROM jobs WHERE created_at < ?",
(cutoff_time.strftime("%Y-%m-%d %H:%M:%S"),)
)
deleted = cursor.rowcount
conn.commit()
conn.close()
if deleted > 0:
print(f"Удалено {deleted} старых записей из базы данных")
def save_job(job_id: str, filename: str, task_type: str, language: str,
speed: float, transcript_text: str):
"""Сохранение задачи в базу данных"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO jobs (id, created_at, filename, task_type, language, speed, transcript_text, api_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
job_id,
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
filename,
task_type,
language,
speed,
transcript_text,
WHISPER_API_URL
))
conn.commit()
conn.close()
def get_history():
"""Получение истории задач"""
cleanup_old_jobs() # Очистка при каждом запросе истории
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
SELECT id, created_at, filename, task_type, language, speed,
substr(transcript_text, 1, 50) as preview
FROM jobs
ORDER BY created_at DESC
LIMIT 50
''')
rows = cursor.fetchall()
conn.close()
if not rows:
return None
# Форматирование для DataFrame
data = []
for row in rows:
data.append({
"ID": row[0][:8],
"Дата": row[1],
"Файл": row[2],
"Задача": row[3],
"Язык": row[4] or "Auto",
"Скорость": f"{row[5]}x",
"Текст": row[6] + "..." if len(row[6]) >= 50 else row[6]
})
return data
def check_api_health():
"""Проверка доступности API"""
try:
api_base = WHISPER_API_URL.split("/transcribe")[0]
response = requests.get(f"{api_base}/health", timeout=5)
if response.status_code == 200:
return True, "Подключено"
except Exception as e:
return False, f"Ошибка: {str(e)}"
return False, "Недоступно"
def process_audio(file_obj, speed, task_type, language, progress=gr.Progress()):
"""
Обработка аудио/видео файла:
1. Извлечение аудио из видео (если нужно)
2. Изменение скорости аудио
3. Отправка на API транскрибации
4. Сохранение результата в БД
"""
if file_obj is None:
raise gr.Error("Пожалуйста, загрузите файл")
progress(0, desc="Инициализация...")
# Генерация уникального ID задачи
job_id = str(uuid.uuid4())
# Определение расширения файла
original_filename = os.path.basename(file_obj.name)
file_ext = os.path.splitext(original_filename)[1].lower()
# Создание временных файлов
with tempfile.TemporaryDirectory() as temp_dir:
input_path = os.path.join(temp_dir, f"input{file_ext}")
output_path = os.path.join(temp_dir, "processed.mp3")
# Копирование загруженного файла
with open(input_path, "wb") as f:
with open(file_obj.name, "rb") as original:
f.write(original.read())
progress(10, desc="Обработка аудио (FFmpeg)...")
# Определение кодека для извлечения аудио
audio_ext = file_ext.lstrip('.')
video_exts = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.flv']
# Если это видео - извлекаем аудио
if file_ext in video_exts:
# Извлечение аудио из видео
cmd_extract = [
"ffmpeg", "-y", "-i", input_path,
"-vn", "-acodec", "libmp3lame", "-q:a", "2",
output_path
]
subprocess.run(cmd_extract, capture_output=True, check=True)
input_path = output_path
# Применение изменения скорости (atempo)
# FFmpeg atempo поддерживает диапазон 0.5-2.0
speed = float(speed)
if speed != 1.0:
# Если скорость вне диапазона atempo, используем несколько проходов
atempo_values = []
remaining_speed = speed
while remaining_speed > 2.0:
atempo_values.append("2.0")
remaining_speed /= 2.0
while remaining_speed < 0.5:
atempo_values.append("0.5")
remaining_speed /= 0.5
atempo_values.append(str(round(remaining_speed, 2)))
# Применяем atempo фильтр
temp_output = os.path.join(temp_dir, "sped.mp3")
cmd_speed = [
"ffmpeg", "-y", "-i", input_path,
"-filter:a", f"atempo={','.join(atempo_values)}",
"-acodec", "libmp3lame", "-q:a", "2",
temp_output
]
subprocess.run(cmd_speed, capture_output=True, check=True)
output_path = temp_output
else:
# Просто конвертируем в mp3 если это аудио
if file_ext not in video_exts:
cmd_convert = [
"ffmpeg", "-y", "-i", input_path,
"-acodec", "libmp3lame", "-q:a", "2",
output_path
]
subprocess.run(cmd_convert, capture_output=True, check=True)
progress(50, desc="Отправка на сервер транскрибации...")
# Чтение обработанного аудио
with open(output_path, "rb") as audio_file:
audio_data = audio_file.read()
# Подготовка данных для API
files = {
"file": ("audio.mp3", audio_data, "audio/mpeg")
}
data = {
"task": task_type,
}
# Добавляем язык если выбран
if language and language != "Автоопределение":
data["language"] = LANGUAGE_CODES.get(language)
progress(70, desc="Транскрибация...")
# Отправка запроса к API
try:
response = requests.post(
WHISPER_API_URL,
files=files,
data=data,
timeout=600 # 10 минут таймаут
)
if response.status_code != 200:
error_msg = response.text
raise gr.Error(f"Ошибка API: {response.status_code} - {error_msg}")
result = response.json()
except requests.exceptions.ConnectionError:
raise gr.Error(f"Не удалось подключиться к API: {WHISPER_API_URL}")
except requests.exceptions.Timeout:
raise gr.Error("Превышен таймаут ожидания ответа от API")
progress(90, desc="Сохранение результатов...")
# Сохранение в базу данных
transcript_text = result.get("text", "")
detected_language = result.get("language", language)
save_job(
job_id=job_id,
filename=original_filename,
task_type=task_type,
language=detected_language,
speed=speed,
transcript_text=transcript_text
)
progress(100, desc="Готово!")
# Возвращаем результаты
# Читаем аудио для проигрывания
with open(output_path, "rb") as f:
audio_bytes = f.read()
return (
output_path, # Audio для проигрывания
transcript_text, # Текст транскрибации
json.dumps(result.get("segments", []), ensure_ascii=False, indent=2), # Сегменты
f"Готово! Язык: {detected_language}, Задача: {task_type}" # Статус
)
def create_ui():
"""Создание Gradio интерфейса"""
# Инициализация БД
init_database()
# Проверка статуса API
api_ok, api_status = check_api_health()
with gr.Blocks(
title="Whisper Speed Transcribe",
theme=gr.themes.Base(
primary_hue="orange",
secondary_hue="gray",
),
css="""
.main-header {
text-align: center;
padding: 20px;
background: linear-gradient(90deg, #1a1a2e 0%, #16213e 100%);
border-radius: 10px;
margin-bottom: 20px;
}
.status-connected {
color: #10b981;
font-weight: bold;
}
.status-disconnected {
color: #ef4444;
font-weight: bold;
}
"""
) as app:
# Заголовок
gr.HTML("""
<div class="main-header">
<h1>🎙️ Whisper Speed Transcribe</h1>
<p>Транскрибация и перевод аудио/видео с изменением скорости</p>
</div>
""")
# Статус API
with gr.Row():
with gr.Column(scale=3):
gr.Markdown(f"**Статус API:** {'🟢 Подключено' if api_ok else '🔴 Не подключено'}")
with gr.Column(scale=1):
gr.Markdown(f"**URL API:** `{WHISPER_API_URL}`")
gr.HTML("<hr>")
# Основной интерфейс
with gr.Row():
# Левая колонка - настройки
with gr.Column(scale=1):
gr.Markdown("### 📁 Загрузка файла")
file_input = gr.File(
label="Видео или аудио файл",
file_count="single",
file_types=[".mp4", ".mkv", ".avi", ".mov", ".webm", ".mp3", ".wav", ".m4a", ".flac", ".ogg"]
)
gr.Markdown("### ⚙️ Настройки обработки")
with gr.Accordion("Настройки аудио", open=True):
speed_slider = gr.Slider(
label="Скорость воспроизведения",
minimum=0.5,
maximum=2.0,
step=0.05,
value=1.25,
info="Ускоряет аудио без изменения высоты тона"
)
with gr.Accordion("Настройки Whisper", open=True):
task_radio = gr.Radio(
label="Задача",
choices=["transcribe", "translate"],
value="transcribe",
info="transcribe - расшифровка, translate - перевод на английский"
)
language_dropdown = gr.Dropdown(
label="Язык (опционально)",
choices=LANGUAGES,
value="Автоопределение",
info="Если не выбрано - определяется автоматически"
)
process_btn = gr.Button(
"🚀 Обработать файл",
variant="primary",
size="lg"
)
status_output = gr.Textbox(
label="Статус",
interactive=False
)
# Правая колонка - результаты
with gr.Column(scale=1):
gr.Markdown("### 🔊 Обработанное аудио")
audio_output = gr.Audio(
label="Аудио после обработки",
interactive=False
)
gr.Markdown("### 📝 Результат транскрибации")
text_output = gr.Textbox(
label="Текст",
lines=10,
interactive=False
)
with gr.Accordion("Сегменты (JSON)", open=False):
segments_output = gr.JSON(
label="Детальные сегменты"
)
gr.HTML("<hr>")
# История задач
gr.Markdown("### 📋 История задач")
with gr.Row():
refresh_btn = gr.Button("🔄 Обновить историю")
history_table = gr.Dataframe(
headers=["ID", "Дата", "Файл", "Задача", "Язык", "Скорость", "Текст"],
datatype=["str", "str", "str", "str", "str", "str", "str"],
label="Последние задачи",
max_height=300
)
def load_history():
data = get_history()
if data:
return data
return []
# Загрузка истории при запуске
history_table.value = load_history()
# Обработчики событий
process_btn.click(
fn=process_audio,
inputs=[file_input, speed_slider, task_radio, language_dropdown],
outputs=[audio_output, text_output, segments_output, status_output]
)
refresh_btn.click(
fn=load_history,
outputs=[history_table]
)
# Автообновление истории каждые 30 секунд
app.load(fn=load_history, outputs=[history_table])
return app
if __name__ == "__main__":
print(f"Запуск Gradio UI...")
print(f"API URL: {WHISPER_API_URL}")
print(f"Хранение задач: {DB_RETENTION_HOURS} часов")
app = create_ui()
app.launch(
server_name="0.0.0.0",
server_port=7860,
share=False
)

3
ui/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
gradio==4.19.2
requests==2.31.0
ffmpeg-python==0.2.0