Speech2Text API - Полная документация
Базовый URL
http://localhost:8020
Аутентификация
Все защищенные endpoints требуют JWT токен в заголовке Authorization:
Authorization: Bearer YOUR_JWT_TOKEN
Для программного доступа в том же заголовке передаётся API-ключ:
Authorization: Bearer sk-XXXXXXXXXXXXXXXX...
Ключу можно задать секретное слово и потребовать, чтобы каждый запрос этим ключом был подписан, — см. Подпись HMAC-SHA256. По умолчанию подпись выключена, и ключ работает как раньше.
Содержание
- Аутентификация
- Транскрибация
- Управление пользователями
- API ключи
- Метрики
- Администрирование
- Перевод на русский (LLM)
- Загрузка по ссылке и обратный вызов
- Подпись HMAC-SHA256
1. Аутентификация
Вход в систему
POST /auth/login
Получение JWT токена для доступа к API.
Параметры запроса:
{
"username": "string",
"password": "string"
}
Ответ:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
Примеры:
Python:
import requests
url = "http://localhost:8020/auth/login"
data = {
"username": "admin",
"password": "admin123"
}
response = requests.post(url, json=data)
token = response.json()["access_token"]
print(f"Token: {token}")
PHP:
<?php
$url = "http://localhost:8020/auth/login";
$data = array(
"username" => "admin",
"password" => "admin123"
);
$options = array(
'http' => array(
'header' => "Content-type: application/json\r\n",
'method' => 'POST',
'content' => json_encode($data)
)
);
$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);
$response = json_decode($result, true);
$token = $response["access_token"];
echo "Token: " . $token;
?>
JavaScript:
async function login() {
const response = await fetch('http://localhost:8020/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'admin',
password: 'admin123'
})
});
const data = await response.json();
const token = data.access_token;
console.log('Token:', token);
return token;
}
cURL:
curl -X POST "http://localhost:8020/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
Регистрация пользователя
POST /auth/register
Создание нового пользователя.
Параметры запроса:
{
"username": "string",
"email": "user@example.com",
"password": "string"
}
Ответ:
{
"id": "uuid",
"username": "string",
"email": "user@example.com",
"is_active": true,
"is_admin": false,
"created_at": "2024-01-01T00:00:00"
}
Примеры:
Python:
import requests
url = "http://localhost:8020/auth/register"
data = {
"username": "newuser",
"email": "newuser@example.com",
"password": "secure_password123"
}
response = requests.post(url, json=data)
user = response.json()
print(f"Created user: {user['username']} with ID: {user['id']}")
JavaScript:
async function registerUser() {
const response = await fetch('http://localhost:8020/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'newuser',
email: 'newuser@example.com',
password: 'secure_password123'
})
});
const user = await response.json();
console.log('Created user:', user);
return user;
}
Получить текущего пользователя
GET /auth/me
Получение информации о текущем авторизованном пользователе.
Заголовки:
Authorization: Bearer YOUR_JWT_TOKEN
Ответ:
{
"id": "uuid",
"username": "string",
"email": "user@example.com",
"is_active": true,
"is_admin": false,
"created_at": "2024-01-01T00:00:00"
}
Примеры:
Python:
import requests
url = "http://localhost:8020/auth/me"
headers = {
"Authorization": f"Bearer {token}"
}
response = requests.get(url, headers=headers)
user = response.json()
print(f"Current user: {user['username']}")
2. Транскрибация
Что можно загружать: форматы, видео, размер
Принимаются расширения .mp3, .mp4, .wav, .m4a, .ogg, .flac, .mp2, .mov, .mxf,
.webm. Всё, что ниже, одинаково относится и к POST /transcribe/, и к POST /transcribe/async,
и к загрузке по ссылке (url).
Видеофайлы отдавайте как есть. .mov, .mxf, .webm (и .mp4) — это видеоконтейнеры; звук
из них извлекает сам сервис через ffmpeg. Отдельно вырезать звуковую дорожку перед загрузкой не
нужно.
Несколько звуковых дорожек сводятся в одну. Вещательный MXF обычно несёт по отдельной моно-дорожке на микрофон. Сервис смешивает все дорожки (с усреднением громкости), а не выбирает первую попавшуюся, — иначе целый микрофон, то есть целый говорящий, выпал бы из расшифровки. Одинаковые дорожки при сведении звучат ровно как исходная, перегрузки по громкости не возникает. Выбрать конкретную дорожку через API нельзя.
Размер. Лимит — MAX_FILE_SIZE_MB, по умолчанию 5000 МБ (раньше 500). Видеоконтейнер с
материалом целиком — это гигабайты, тогда как звук в нём занимает проценты; раз извлечением
занимается сервис, лимит пропускает исходник. Тем же лимитом ограничена и загрузка по ссылке.
Превышение — 413. Исключение — ссылка в POST /transcribe/async: её файл скачивается уже после
ответа 202, поэтому слишком большой файл не даёт 413, а переводит задачу в failed (см.
раздел 8).
Файл без звука. Если в файле нет ни одной звуковой дорожки, задача завершается со статусом
failed, а в error_message приходит: «В файле нет звуковой дорожки — распознавать нечего.»
Транскрибировать аудио
POST /transcribe/
Загрузка и транскрибация аудио- или видеофайла.
Параметры:
- file (form-data): Аудио- или видеофайл (MP3, MP4, WAV, M4A, OGG, FLAC, MP2, MOV, MXF, WEBM — см. «Что можно загружать»). Взаимоисключающе с
url: нужно задать ровно один источник - url (form-data, optional): Ссылка
http(s)на аудио — сервис скачает файл сам (/transcribe/— внутри запроса,/transcribe/async— в фоне, уже после ответа202). Подробности и ошибки скачивания: раздел 8 - callback_url (form-data, optional): Адрес
http(s), на который придётPOSTс готовой транскрипцией. Подробности: раздел 8 - language (optional): Код языка (ru, en, es, etc.) или
autoдля автоопределения языка записи. Если поле не передано или пустое, используется язык сервера по умолчанию (WHISPER_DEFAULT_LANGUAGE, обычноru) — автоопределения при этом нет. Исключение: вместе сtranslate_toпустойlanguageозначаетauto. Подробнее — в разделе Исходный язык и автоопределение - task (optional): "transcribe" или "translate" (по умолчанию "transcribe").
translate— встроенный перевод Whisper на английский; для перевода на русский используйтеtranslate_to - translate_to (optional): перевести расшифровку с помощью LLM, например
ru— см. раздел 7. Перевод на русский (LLM) - wait_translation (optional, только
/transcribe/): ждать ли готовый перевод перед ответом (по умолчаниюtrue) - return_segments (optional): true/false для получения временных сегментов
- return_timestamps (optional): true/false для получения пословных меток времени (word-level timestamps)
- diarize (optional): true/false — разделение по собеседникам (диаризация). Каждый сегмент получает метку
speaker, в ответ добавляютсяdiarization.speakersи готовый диалоговый текстdiarization.text_with_speakers - num_speakers (optional): точное число собеседников, если известно (например, 2 для телефонного опроса) — повышает стабильность разметки
- min_speakers / max_speakers (optional): границы для автоматического определения числа собеседников
Ответ:
{
"id": "uuid",
"filename": "audio.mp3",
"text": "Транскрибированный текст...",
"language": "ru",
"duration": 120.5,
"processing_time": 15.3,
"status": "completed",
"created_at": "2024-01-01T00:00:00",
"segments": [
{
"id": 0,
"start": 0.0,
"end": 5.2,
"text": "Первый сегмент текста",
"speaker": "SPEAKER_01", // Только если diarize=true
"words": [ // Только если return_timestamps=true
{
"word": "Первый",
"start": 0.0,
"end": 0.8,
"probability": 0.95
},
{
"word": "сегмент",
"start": 0.8,
"end": 1.5,
"probability": 0.98
}
]
}
],
"has_diarization": true, // Только если diarize=true
"diarization": { // Только если diarize=true
"num_speakers": 2,
"speakers": [
{"id": "SPEAKER_01", "label": "Собеседник 1", "talk_time": 75.4, "segments": 12},
{"id": "SPEAKER_02", "label": "Собеседник 2", "talk_time": 44.1, "segments": 11}
],
"text_with_speakers": "Собеседник 1: Здравствуйте!...\n\nСобеседник 2: Добрый день..."
},
"language_detection": null, // Результат автоопределения — только при language=auto
"translation": null // Перевод (LLM) — только если запрошен translate_to
}
Поле
languageсодержит код, с которым запись была распознана. Поляlanguage_detectionиtranslationприсутствуют в ответе всегда и равныnull, если автоопределение и перевод не запрашивались. Их формат описан в разделе 7. Перевод на русский (LLM).
В ответе (и в GET /transcriptions/{id}, и в теле обратного вызова) присутствуют ещё поля источника
и доставки — они заполняются, только если запись пришла по ссылке и/или был заказан callback:
| Поле | Значение |
|---|---|
source_url |
Ссылка, с которой скачано аудио, с вырезанными секретами (null для загрузки файлом) |
callback_url |
Куда доставляется результат (null, если вызов не заказан) |
callback_status |
null · pending · sending · delivered · failed |
callback_attempts |
Сколько попыток доставки сделано |
callback_error |
Причина последней неудачи |
callback_delivered_at |
Когда доставлено (UTC) |
Пример с диаризацией (разделение собеседников):
data = {
"language": "ru",
"diarize": "true", # Включить разделение по собеседникам
"num_speakers": "2" # Опционально: точное число собеседников
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(result["diarization"]["text_with_speakers"]) # Готовый диалог
for seg in result["segments"]:
print(f"[{seg.get('speaker')}] {seg['start']:.1f}-{seg['end']:.1f}: {seg['text']}")
Пример с автоопределением языка и переводом на русский:
data = {
"language": "auto", # Определить язык записи автоматически
"translate_to": "ru" # После распознавания перевести текст на русский (LLM)
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(result["language"]) # Язык записи, например "en"
translation = result["translation"]
if translation and translation["status"] == "completed":
print(translation["text"]) # Перевод на русский
Примеры:
Python:
import requests
url = "http://localhost:8020/transcribe/"
headers = {
"Authorization": f"Bearer {token}"
}
with open("audio.mp3", "rb") as audio_file:
files = {"file": audio_file}
data = {
"language": "ru",
"return_segments": "true",
"return_timestamps": "true" # Включить пословные метки времени
}
response = requests.post(url, headers=headers, files=files, data=data)
result = response.json()
print(f"Transcription ID: {result['id']}")
print(f"Text: {result['text'][:100]}...")
print(f"Duration: {result['duration']} seconds")
# Обработка меток времени
if "segments" in result:
for segment in result["segments"]:
print(f"\nSegment {segment['id']}: {segment['start']:.1f}s - {segment['end']:.1f}s")
if "words" in segment:
for word in segment["words"]:
print(f" {word['word']}: {word['start']:.1f}s - {word['end']:.1f}s")
PHP:
<?php
$url = "http://localhost:8020/transcribe/";
$token = "YOUR_JWT_TOKEN";
$file = new CURLFile('audio.mp3');
$data = array(
'file' => $file,
'language' => 'ru',
'return_segments' => 'false'
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
"Authorization: Bearer " . $token
));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
echo "Text: " . $result['text'];
?>
JavaScript:
async function transcribeAudio(file, token) {
const formData = new FormData();
formData.append('file', file);
formData.append('language', 'ru');
formData.append('return_segments', 'false');
const response = await fetch('http://localhost:8020/transcribe/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
console.log('Transcription:', result.text);
return result;
}
// Использование с input файла
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
await transcribeAudio(file, token);
});
cURL:
curl -X POST "http://localhost:8020/transcribe/" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-F "file=@audio.mp3" \
-F "language=ru" \
-F "return_segments=false"
⏳ Асинхронная транскрибация (очередь)
POST /transcribe/async
Ставит запись в очередь и сразу возвращает задачу со статусом pending, не удерживая
соединение. Параметры — те же, что у /transcribe/, включая url и callback_url (кроме
wait_translation: асинхронный вызов ничего не ждёт). Перевод, запрошенный через translate_to,
выполняется в фоне после распознавания — его состояние отдаёт GET /transcriptions/{id}/translation.
Именно здесь пара url + callback_url работает как задумано: клиент отдаёт ссылку на запись
и адрес приёмника, получает id и больше ничего не опрашивает — готовый текст приедет сам
(см. раздел 8).
Ссылка (url) скачивается уже после ответа. До ответа 202 сервис только проверяет запрос:
ровно один источник (file или url), translate_to, callback_url, границы
num_speakers/min_speakers/max_speakers и саму ссылку — схема http(s), корректный адрес с
хостом, хост резолвится и не ведёт в приватную сеть (FETCH_ALLOW_PRIVATE_HOSTS). Эти отказы
по-прежнему приходят кодом 400/403 на сам запрос. Всё остальное — ответ источника 4xx/5xx,
пустой файл, размер больше MAX_FILE_SIZE_MB, дедлайн, неподдерживаемый тип файла, редирект на
запрещённый адрес — выясняется в фоне и кодом ответа уже не становится: задача переходит в
failed, а причина лежит в error_message — «Не удалось скачать файл по ссылке: …» (подробно —
в разделе 8). Пока файл скачивается, задача остаётся
pending с progress = 0 и queue_position = null; в очередь она встаёт, когда файл получен.
Загрузка файлом (file) работает как раньше.
Все загрузки (и синхронные, и асинхронные) проходят через одну персистентную очередь:
задача хранится в базе вместе с параметрами запуска, поэтому перезапуск или отключение
питания её не теряет — при следующем старте сервер сам возвращает прерванные задачи
в очередь и доводит их до конца. Одновременно обрабатывается одна запись, остальные ждут
своей очереди. Скачивание по ссылке, прерванное перезапуском, при следующем старте начинается
заново, с начала (докачки нет); presigned-ссылка, истёкшая за это время, даёт failed с
HTTP-ошибкой источника.
Ответ (202 Accepted):
{
"id": "uuid",
"filename": "audio.mp3",
"status": "pending",
"text": null,
"created_at": "2026-07-27T17:31:17"
}
Для url поле filename в ответе предварительное: последний сегмент пути ссылки (или audio,
если его нет). После скачивания его заменяет имя, которое отдал источник, — из
Content-Disposition, иначе из пути конечного адреса после редиректов.
Жизненный цикл задачи:
pending → processing → completed | failed | cancelled
У задачи с url стадия pending начинается со скачивания: pending (файл скачивается) →
pending (в очереди) → processing → completed | failed | cancelled. Неудачное скачивание —
сразу pending → failed. Отмена задачи администратором или DELETE /transcriptions/{id} во
время скачивания останавливают его, и задача уже не оживает. Пока файла нет,
GET /transcriptions/{id}/audio отвечает 404, а GET /transcriptions/{id}/download — 400.
Прогресс и позицию в очереди отдаёт GET /transcriptions/{id}/status, готовый результат —
GET /transcriptions/{id}.
Пример:
import requests, time
headers = {"Authorization": f"Bearer {token}"}
with open("audio.mp3", "rb") as f:
job = requests.post(
"http://localhost:8020/transcribe/async",
headers=headers,
files={"file": f},
data={"language": "ru", "return_timestamps": True},
).json()
while True:
state = requests.get(
f"http://localhost:8020/transcriptions/{job['id']}/status", headers=headers
).json()
if state["status"] in ("completed", "failed", "cancelled"):
break
print(f"{state['status']}: {state['progress']}%, в очереди перед вами: {state['queue_position']}")
time.sleep(5)
result = requests.get(
f"http://localhost:8020/transcriptions/{job['id']}", headers=headers
).json()
print(result["text"])
Синхронный
POST /transcribe/работает как раньше и возвращает готовый текст, но если обработка не уложилась вQUEUE_SYNC_WAIT_SECONDS(по умолчанию час), он ответит504с id задачи — задача при этом не теряется и продолжает выполняться.
История транскрибаций
GET /transcribe/history
Получение списка всех транскрибаций пользователя.
Параметры:
- skip (optional): Количество записей для пропуска (пагинация)
- limit (optional): Максимальное количество записей (по умолчанию 100)
Ответ:
[
{
"id": "uuid",
"filename": "audio.mp3",
"status": "completed",
"duration": 120.5,
"language": "ru",
"created_at": "2024-01-01T00:00:00",
"translation_status": null // Статус перевода (LLM) или null, если перевод не запрашивался
}
]
Примеры:
Python:
import requests
url = "http://localhost:8020/transcribe/history"
headers = {
"Authorization": f"Bearer {token}"
}
params = {
"skip": 0,
"limit": 50
}
response = requests.get(url, headers=headers, params=params)
history = response.json()
for item in history:
print(f"{item['filename']} - {item['status']} - {item['created_at']}")
Получить транскрибацию
GET /transcribe/{transcription_id}
Получение полной информации о конкретной транскрибации.
Ответ:
{
"id": "uuid",
"filename": "audio.mp3",
"text": "Полный текст транскрибации...",
"language": "ru",
"duration": 120.5,
"processing_time": 15.3,
"status": "completed",
"created_at": "2024-01-01T00:00:00",
"segments": []
}
Стриминг аудио
GET /transcriptions/{transcription_id}/audio
Получение аудиофайла для воспроизведения.
Примеры:
Python:
import requests
url = f"http://localhost:8020/transcriptions/{transcription_id}/audio"
headers = {
"Authorization": f"Bearer {token}"
}
response = requests.get(url, headers=headers, stream=True)
# Сохранить аудио
with open("downloaded_audio.mp3", "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
JavaScript:
async function playAudio(transcriptionId, token) {
const response = await fetch(
`http://localhost:8020/transcriptions/${transcriptionId}/audio`,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.play();
}
Статус транскрибации
GET /transcriptions/{transcription_id}/status
Проверка статуса обработки транскрибации.
Ответ:
{
"id": "uuid",
"status": "processing",
"progress": 42,
"queue_position": null,
"attempts": 1,
"filename": "audio.mp3",
"created_at": "2024-01-01T00:00:00",
"queued_at": "2024-01-01T00:00:00",
"started_at": "2024-01-01T00:00:05",
"completed_at": null,
"duration": null,
"processing_time": null,
"error_message": null,
"translation_status": null,
"translation_progress": null,
"translation_error": null
}
- status:
pending(ждёт в очереди, а у задачи/transcribe/asyncсurl— ещё и пока скачивается файл),processing,completed,failed,cancelled - queue_position: место в очереди для
pending, иначеnull. Задача сurl, чей файл ещё скачивается, в очереди пока не стоит: у неёqueue_position=null,progress= 0, аfilename— предварительное имя из ссылки. В очередь она встаёт, когда файл получен, иqueued_atтогда становится временем получения файла - attempts: сколько раз задачу брали в работу (растёт, если её прервал перезапуск сервера)
- translation_status, translation_progress, translation_error: статус перевода (LLM),
его прогресс в процентах и сообщение об ошибке; все три равны
null, если перевод не запрашивался. Статус транскрибации (status) от перевода не зависит — значения статусов перевода описаны в разделе Статусы и причины - error_message: причина при
failed. Частый случай для видео — «В файле нет звуковой дорожки — распознавать нечего.»: контейнер приняли, но распознавать в нём нечего. У задачи/transcribe/asyncсurlздесь же причина неудачного скачивания: «Не удалось скачать файл по ссылке: …» (ссылки в тексте — с вырезанными секретами, см. раздел 8)
Примеры:
Python:
import requests
import time
def wait_for_transcription(transcription_id, token):
url = f"http://localhost:8020/transcriptions/{transcription_id}/status"
headers = {"Authorization": f"Bearer {token}"}
while True:
response = requests.get(url, headers=headers)
status = response.json()
if status['status'] == 'completed':
print("Transcription completed!")
return True
elif status['status'] == 'failed':
print(f"Transcription failed: {status['error_message']}")
return False
print(f"Status: {status['status']}... waiting")
time.sleep(2)
Скачать транскрибацию
GET /transcriptions/{transcription_id}/download
Скачивание текста транскрибации в различных форматах.
Параметры:
- format: "txt" или "srt" (по умолчанию "txt")
- translated (optional):
true— скачать перевод (LLM) вместо оригинала (по умолчаниюfalse). Пока перевод не готов, ответ —409«Перевод ещё не готов». Файлы перевода называются<имя>_<язык перевода>.txt/.srt, напримерinterview_ru.srt
Имя файла передаётся в Content-Disposition. Имя только из латиницы, цифр и знаков
._- (token по RFC 7230) — в прежнем виде, attachment; filename=interview_transcription.txt.
Любое другое — по RFC 6266: ASCII-замена в кавычках в filename и точное имя в
filename*=UTF-8''…, поэтому файлы с кириллицей или пробелами в имени скачиваются корректно.
Примеры:
Python:
import requests
# Скачать как текст
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=txt"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
with open("transcription.txt", "w", encoding="utf-8") as f:
f.write(response.text)
# Скачать как субтитры SRT
url = f"http://localhost:8020/transcriptions/{transcription_id}/download?format=srt"
response = requests.get(url, headers=headers)
with open("transcription.srt", "w", encoding="utf-8") as f:
f.write(response.text)
3. Управление пользователями
Список пользователей (Admin)
GET /admin/users
Получение списка всех пользователей (требуются права администратора).
Ответ:
[
{
"id": "uuid",
"username": "user1",
"email": "user1@example.com",
"is_active": true,
"is_admin": false,
"created_at": "2024-01-01T00:00:00"
}
]
Обновить пользователя (Admin)
PATCH /admin/users/{user_id}
Обновление данных пользователя.
Параметры:
{
"username": "new_username",
"email": "new@example.com",
"password": "new_password",
"is_active": true,
"is_admin": false
}
Примеры:
Python:
import requests
url = f"http://localhost:8020/admin/users/{user_id}"
headers = {
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json"
}
data = {
"is_active": False,
"is_admin": True
}
response = requests.patch(url, headers=headers, json=data)
updated_user = response.json()
print(f"Updated user: {updated_user}")
Удалить пользователя (Admin)
DELETE /admin/users/{user_id}
Удаление пользователя из системы.
Примеры:
JavaScript:
async function deleteUser(userId, token) {
const response = await fetch(`http://localhost:8020/admin/users/${userId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
console.log('User deleted successfully');
} else {
console.error('Failed to delete user');
}
}
4. API Ключи
Создать API ключ
POST /api-keys/
Создание нового API ключа для программного доступа.
Параметры:
{
"name": "My Application",
"permissions": ["transcribe", "read"],
"rate_limit": 100,
"expires_in_days": 365,
"webhook_secret": null,
"generate_webhook_secret": false,
"sign_callbacks": false,
"require_request_signature": false
}
| Поле подписи | По умолчанию | Описание |
|---|---|---|
webhook_secret |
null |
Своё секретное слово, от 8 до 256 символов (иначе 422) |
generate_webhook_secret |
false |
Пусть секрет придумает сервер: whsec_ + 43 случайных символа |
sign_callbacks |
false |
Подписывать исходящие вызовы на callback_url |
require_request_signature |
false |
Требовать подпись у входящих запросов этим ключом |
Задать одновременно webhook_secret и generate_webhook_secret нельзя, включить любой из
выключателей без секретного слова — тоже (400, тексты — в разделе 9).
Ответ:
{
"id": "uuid",
"name": "My Application",
"api_key": "sk-abcdef123456789...",
"key_prefix": "sk-abcdef1...",
"is_active": true,
"permissions": ["transcribe", "read"],
"rate_limit": 100,
"created_at": "2024-01-01T00:00:00",
"last_used_at": null,
"expires_at": "2025-01-01T00:00:00",
"sign_callbacks": false,
"require_request_signature": false,
"webhook_secret_set": false,
"webhook_secret_hint": null,
"webhook_secret": null
}
Важно: Полный ключ (api_key) показывается только один раз при создании!
Поле webhook_secret заполняется, только если секрет был задан или сгенерирован этим же
вызовом: его надо увидеть один раз, чтобы передать интеграции. В списке ключей его больше не будет —
там видно лишь webhook_secret_set (есть ли секрет) и маску webhook_secret_hint
(whsec_••••••••). Полное значение отдаёт отдельная ручка
GET /api-keys/{key_id}/webhook-secret.
Примеры:
Python:
import requests
url = "http://localhost:8020/api-keys/"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
data = {
"name": "Python Client",
"permissions": ["transcribe"],
"rate_limit": 1000,
"expires_in_days": 90
}
response = requests.post(url, headers=headers, json=data)
api_key = response.json()
print(f"API Key created: {api_key['key']}")
print("Save this key securely! It won't be shown again.")
# Использование API ключа вместо JWT токена
api_headers = {
"X-API-Key": api_key['key']
}
Список API ключей
GET /api-keys/
Получение списка всех API ключей пользователя.
Ответ:
[
{
"id": "uuid",
"name": "My Application",
"key_prefix": "sk-abcdef1...",
"is_active": true,
"permissions": ["transcribe", "read"],
"rate_limit": 100,
"created_at": "2024-01-01T00:00:00",
"last_used_at": "2024-01-15T12:00:00",
"expires_at": "2025-01-01T00:00:00",
"sign_callbacks": true,
"require_request_signature": false,
"webhook_secret_set": true,
"webhook_secret_hint": "whsec_••••••••"
}
]
Сам секрет в список не попадает никогда — только признак его наличия и маска.
Изменить ключ (имя и настройки подписи)
PATCH /api-keys/{key_id}
Меняет только переданные поля; чего нет в теле — остаётся как было. Доступны только собственные
ключи пользователя, иначе 404 {"detail": "API-ключ не найден"}.
Параметры:
{
"name": "Production key",
"webhook_secret": "my-shared-secret",
"generate_webhook_secret": false,
"sign_callbacks": true,
"require_request_signature": false
}
| Поле | Значение |
|---|---|
name |
Новое имя. null — не менять |
webhook_secret |
Новое секретное слово. null — не менять, "" — удалить секрет |
generate_webhook_secret |
true — сервер сам придумает новый секрет и вернёт его в ответе |
sign_callbacks |
true/false — включить/выключить подпись callback. null — не менять |
require_request_signature |
true/false — требовать подпись входящих. null — не менять |
Ответ:
Запись ключа (APIKeyFullResponse, то есть с полем api_key) с актуальными sign_callbacks,
require_request_signature, webhook_secret_set, webhook_secret_hint.
Поле webhook_secret заполняется только при
generate_webhook_secret: true — своё слово вызывающий и так знает, а сгенерированное больше
нигде не показать.
Ошибки 400:
| Текст | Когда |
|---|---|
Укажите либо своё секретное слово, либо генерацию — но не оба сразу |
Переданы и webhook_secret, и generate_webhook_secret: true |
Включить подпись нельзя: у ключа не задано секретное слово |
Итоговое состояние — выключатель включён, секрета нет |
Сначала выключите подпись — без секретного слова она не работает |
webhook_secret: "" при хотя бы одном включённом выключателе |
Чтобы удалить секрет, гасите подпись тем же запросом:
curl -X PATCH "http://localhost:8020/api-keys/$KEY_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"webhook_secret":"","sign_callbacks":false,"require_request_signature":false}'
Показать секретное слово
GET /api-keys/{key_id}/webhook-secret
Возвращает секрет полностью — его нужно как-то передать той стороне, которая будет
проверять подписи. Только для своих ключей (иначе 404). Факт раскрытия попадает в лог сервера
(кто и у какого ключа смотрел; само значение в лог не пишется).
Ответ:
{
"id": "uuid",
"webhook_secret": "whsec_2Vd1kZ0oQ7pR..."
}
Если секрет не задан — "webhook_secret": null.
5. Метрики
Системные метрики
GET /metrics/system
Получение метрик системы и статистики использования.
Ответ:
{
"cpu_usage": 45.2,
"memory_usage": 62.8,
"disk_usage": 35.1,
"gpu_available": true,
"gpu_memory_used": "8.5 GB",
"whisper_model": "large-v3",
"active_users": 12,
"total_transcriptions_24h": 156,
"average_processing_time": 12.3,
"uptime_seconds": 86400
}
Примеры:
Python:
import requests
url = "http://localhost:8020/metrics/system"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
metrics = response.json()
print(f"CPU Usage: {metrics['cpu_usage']}%")
print(f"GPU Available: {metrics['gpu_available']}")
print(f"Model: {metrics['whisper_model']}")
print(f"Transcriptions (24h): {metrics['total_transcriptions_24h']}")
Пользовательские метрики
GET /metrics/user
Получение статистики использования для текущего пользователя.
Ответ:
{
"total_transcriptions": 523,
"total_duration_seconds": 125840.5,
"total_characters": 2456789,
"total_api_calls": 1523,
"monthly_transcriptions": 45,
"monthly_duration_seconds": 10234.2,
"monthly_characters": 234567,
"monthly_api_calls": 156,
"last_reset_date": "2024-01-01T00:00:00"
}
6. Администрирование
Все транскрибации (Admin)
GET /admin/transcriptions
Получение списка всех транскрибаций в системе.
Ответ:
[
{
"id": "uuid",
"filename": "audio.mp3",
"status": "completed",
"duration": 120.5,
"language": "ru",
"created_at": "2024-01-01T00:00:00",
"user_id": "uuid",
"username": "user1",
"translation_status": null // Статус перевода (LLM) или null
}
]
Административные эндпоинты перевода (настройки, очередь переводов, повтор и отмена) описаны в разделе 7. Перевод на русский (LLM).
7. Перевод на русский (LLM)
Сервис может перевести готовую расшифровку на русский язык с помощью большой языковой модели.
Язык записи указывается при загрузке или определяется автоматически. Переводит
OpenAI-совместимый сервер LLM (/v1/chat/completions) — штатно LM Studio с моделью
Qwen3.6-35B-A3B; подойдут и vLLM, Ollama и другие совместимые серверы.
Как это устроено:
- Перевод — отдельная фоновая задача со своей очередью и своим статусом. Она начинается
только после того, как распознавание завершилось, и никак на него не влияет: поле
statusтранскрибации никогда не отражает перевод, а ошибка, медленный или недоступный сервер LLM не превращают успешную транскрибацию в ошибку. - Модуль выключен по умолчанию. Его включает администратор в админ-панели:
Система → Перевод (LLM). Там же задаются адрес сервера LLM, ключ, модель и все лимиты
(см. Настройки перевода). Настройки хранятся в базе данных, а не
в
.env, и применяются без перезапуска сервиса. - Запросы без
translate_toработают как раньше — те же параметры, тот же язык распознавания, те же статусы и ответы (в ответах лишь появились поля, равныеnull). - В веб-интерфейсе перевод включается переключателем при загрузке, результат открывается на вкладке «Перевод»; готовую запись можно перевести из истории.
- Текст расшифровки передаётся на сервер LLM, который указал администратор.
Исходный язык и автоопределение (language)
Поле language одинаково работает во всех эндпоинтах загрузки: /transcribe/,
/transcribe/async, /v1/audio/transcriptions и /v1/audio/translations.
Значение language |
Что происходит |
|---|---|
код языка (en, de, ru, …) |
запись распознаётся на указанном языке — как раньше |
auto или detect (регистр не важен) |
язык определяется автоматически, затем запись распознаётся на нём |
| не передано или пустое | язык сервера по умолчанию (WHISPER_DEFAULT_LANGUAGE, обычно ru), без автоопределения — как раньше |
не передано или пустое, но задан translate_to |
auto: при переводе язык записи определяется автоматически |
У /v1/audio/translations значение language по умолчанию — auto.
Как работает автоопределение. Встроенное автоопределение Whisper в сервисе не
используется: на части записей оно приводило к пропуску первых 6–30 секунд речи. Вместо него
сервис перед распознаванием берёт до четырёх 30-секундных фрагментов из разных частей записи
(для записей до 45 секунд — один фрагмент с начала), отбрасывает тишину, определяет язык каждого
фрагмента и объединяет результаты с учётом уверенности. После этого запись распознаётся с явно
заданным языком — точно так же, как при language=en.
Если уверенность ниже порога (по умолчанию 0.5) или самый вероятный язык опережает второй
меньше чем на заданный отрыв (по умолчанию 0 — отрыв не проверяется), в записи нет речи или
определить язык не удалось, используется язык отката (по умолчанию — WHISPER_DEFAULT_LANGUAGE),
а в результате выставляется "fallback": true. Порог, отрыв и язык отката задаёт администратор:
Система → Автоопределение языка (см. Настройки автоопределения).
Значения по умолчанию берутся из .env (LANGUAGE_DETECT_MIN_PROBABILITY,
LANGUAGE_DETECT_MIN_MARGIN, WHISPER_DEFAULT_LANGUAGE); изменения применяются к следующей
задаче без перезапуска сервиса (воркеры перечитывают их не реже раза в 5 секунд).
Результат возвращается в поле language_detection (только при language=auto, иначе null),
а поле language содержит код, с которым запись была распознана:
{
"language": "en",
"language_detection": {
"requested": "auto",
"detected": "en",
"probability": 0.97,
"used": "en",
"fallback": false,
"fallback_reason": null,
"candidates": [["en", 0.97], ["de", 0.01], ["nl", 0.005]],
"windows": [{"offset": 0.0, "language": "en", "probability": 0.98}]
}
}
| Поле | Описание |
|---|---|
requested |
что было запрошено (auto) |
detected, probability |
самый вероятный язык и его вероятность (0–1) — даже если он не был принят |
used |
язык, с которым запись распознана (совпадает с language ответа) |
fallback |
true — определение ненадёжно, использован язык по умолчанию |
fallback_reason |
null, low_confidence (уверенность ниже порога или слишком малый отрыв от второго языка), silence (в записи нет речи) или error (сбой определения, подробность — в поле error) |
candidates |
самые вероятные языки: пары [код, вероятность] |
windows |
проанализированные фрагменты: смещение от начала (сек), язык, вероятность |
Перевод при загрузке (translate_to, wait_translation)
Эндпоинты POST /transcribe/, POST /transcribe/async и POST /v1/audio/transcriptions
принимают два дополнительных поля (form-data):
| Поле | По умолчанию | Описание |
|---|---|---|
translate_to |
не задано — без перевода | язык перевода: ru. Значения default, true, 1, yes, on — язык перевода из настроек (по умолчанию русский). Пустое значение, none, false, 0, off — без перевода |
wait_translation |
true |
только синхронные /transcribe/ и /v1/audio/transcriptions: ждать ли перевод перед ответом. false — ответить сразу после распознавания, перевод выполнится в фоне. У /v1/audio/transcriptions действует только для response_format=json и verbose_json: ответы text, srt и vtt перевод не содержат и приходят сразу после распознавания |
Правила:
translate_toпроверяется до сохранения файла. Неизвестный язык —400«Неподдерживаемый язык перевода: …». Модуль рассчитан на перевод на русский; другие коды языков из списка Whisper тоже принимаются.task=translate— это встроенный перевод Whisper на английский. Совмещать его сtranslate_toнельзя —400«task=translate (перевод Whisper на английский) нельзя совмещать с translate_to».- Если задан
translate_to, аlanguageне передан, язык записи определяется автоматически. - Если администратор выключил перевод, загрузка всё равно выполняется как обычно, а перевод
получает статус
skippedс причинойdisabled. - Синхронный
/transcribe/после распознавания ждёт перевод не дольшеsync_wait_seconds(настройка администратора, по умолчанию 600 секунд) и возвращает ответ раньше, если сервер LLM недоступен или перевод выключен. Если перевод не успел завершиться, объектtranslationпридёт в состоянииpendingилиprocessing— перевод продолжится в фоне, результат отдаётGET /transcriptions/{id}/translation. Таймаут HTTP-клиента должен быть не меньшеQUEUE_SYNC_WAIT_SECONDS+sync_wait_seconds(по умолчанию 3600 + 600 секунд)./v1/audio/transcriptionsждёт так же, но только дляresponse_format=jsonиverbose_json. - Перевод никогда не превращает ответ в ошибку: результат распознавания возвращается всегда,
а состояние перевода — в объекте
translation.
Примеры:
cURL (синхронно — ответ придёт вместе с переводом):
curl -X POST "http://localhost:8020/transcribe/" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@interview_en.mp3" \
-F "language=auto" \
-F "translate_to=ru" \
-F "return_segments=true"
Python (асинхронно, с ожиданием перевода):
import requests
import time
BASE = "http://localhost:8020"
headers = {"Authorization": f"Bearer {token}"}
# 1. Загрузка: язык определить автоматически, текст перевести на русский
with open("interview_en.mp3", "rb") as f:
job = requests.post(
f"{BASE}/transcribe/async",
headers=headers,
files={"file": f},
data={"language": "auto", "translate_to": "ru", "diarize": "true"},
).json()
# 2. Перевод начнётся сам, как только завершится распознавание
url = f"{BASE}/transcriptions/{job['id']}/translation"
while True:
r = requests.get(url, headers=headers, params={"include_content": "false"})
if r.status_code == 404:
raise SystemExit("Перевод не запрашивался")
info = r.json()
if info["status"] in ("completed", "failed", "skipped", "cancelled"):
break
print(f"{info['status']}: {info['progress']}% {info['blocked_by'] or ''}")
time.sleep(5)
# 3. Результат с текстом (include_content=true по умолчанию)
info = requests.get(url, headers=headers).json()
if info["status"] == "completed":
print(info["text_with_speakers"] or info["text"])
else:
print(f"Перевод не выполнен: {info['reason']} — {info['error']}")
Фрагмент ответа /transcribe/ с переводом:
{
"id": "uuid",
"filename": "interview_en.mp3",
"text": "Hello! Thanks for coming.",
"language": "en",
"status": "completed",
"language_detection": {"requested": "auto", "detected": "en", "probability": 0.97, "used": "en", "fallback": false},
"translation": {
"id": "uuid",
"transcription_id": "uuid",
"status": "completed",
"target_language": "ru",
"source_language": "en",
"text": "Здравствуйте! Спасибо, что пришли.",
"progress": 100
}
}
Объект translation
Один формат во всех ответах: GET /transcriptions/{id}, ответы загрузки и эндпоинты перевода.
{
"id": "uuid",
"transcription_id": "uuid",
"status": "completed",
"reason": null,
"blocked_by": null,
"waiting_for_transcription": false,
"target_language": "ru",
"source_language": "en",
"text": "Здравствуйте! Спасибо, что пришли.",
"segments": [
{"id": 0, "start": 0.0, "end": 2.5, "text": "Здравствуйте!", "speaker": "SPEAKER_01"},
{"id": 1, "start": 2.5, "end": 4.1, "text": "Спасибо, что пришли.", "speaker": "SPEAKER_02"}
],
"text_with_speakers": "Собеседник 1: Здравствуйте!\n\nСобеседник 2: Спасибо, что пришли.",
"model": "qwen/qwen3.6-35b-a3b",
"error": null,
"progress": 100,
"progress_done": 1,
"progress_total": 1,
"requested_at": "2026-10-07T10:00:00",
"started_at": "2026-10-07T10:01:12",
"completed_at": "2026-10-07T10:01:24",
"processing_time": 12.3
}
| Поле | Описание |
|---|---|
status |
pending → processing → completed, failed, skipped или cancelled |
reason |
машинно-читаемая причина (см. ниже) или null |
blocked_by |
почему перевод ждёт: transcription (распознавание ещё идёт), service_disabled (перевод выключен), llm_unavailable (сервер LLM или модель недоступны), whisper_busy (пауза, пока Whisper распознаёт другие записи) или null. Подсказка для интерфейса, вычисляется приблизительно |
waiting_for_transcription |
true, пока распознавание записи не завершилось |
target_language |
язык перевода |
source_language |
исходный язык; null — переведено как речь на нескольких языках |
text |
переведённый текст |
segments |
переведённые сегменты: id, start, end и speaker совпадают с оригиналом, text — перевод. Сегментов столько же и в том же порядке, что и в оригинале: у пустого сегмента оригинала пустой и text перевода |
text_with_speakers |
перевод в виде диалога по собеседникам, если запись размечена диаризацией; иначе null |
model |
модель, которая фактически выполнила перевод |
error |
понятное сообщение об ошибке или null |
progress, progress_done, progress_total |
прогресс в процентах и в переведённых фрагментах; у completed всегда 100 |
requested_at, started_at, completed_at |
время в UTC (ISO 8601, без часового пояса) |
processing_time |
длительность перевода, сек |
С include_content=false поля text, segments и text_with_speakers равны null — так
удобно часто опрашивать статус. Административные эндпоинты дополнительно возвращают
error_detail (техническая подробность ошибки), attempts (число попыток), usage (токены:
prompt_tokens, completion_tokens, reasoning_tokens, а также requests, retries,
untranslated — строки, которые модель оставила без перевода, и requeues — сколько раз перевод
возвращался в очередь из-за сбоев сервера LLM без расхода попытки) и source (откуда запрошен
перевод: web — загрузка со страницы /upload или из Студии админки, api, api-async,
openai, openai-translations, user, admin).
Статусы и причины (reason)
status |
reason |
Значение |
|---|---|---|
pending |
null |
в очереди на перевод или ждёт завершения распознавания |
pending |
llm_unavailable |
сервер LLM был недоступен или отклонил запрос (в том числе 401/403 — неверный API-ключ) — перевод повторится автоматически |
pending |
requeued |
запись отправлена на повторное распознавание — перевод будет выполнен заново |
pending |
interrupted |
сервис перезапускался во время перевода — перевод продолжится |
processing |
null |
переводится |
completed |
null |
перевод готов |
skipped |
disabled |
перевод выключен администратором |
skipped |
same_language |
язык записи совпадает с языком перевода |
skipped |
no_text |
в расшифровке нет текста |
skipped |
detection_uncertain |
язык записи не удалось определить уверенно; загрузите запись повторно, указав language |
skipped |
transcription_failed |
распознавание завершилось ошибкой или было отменено |
failed |
llm_auth |
в текущей версии не выставляется: при ответе 401/403 переводы ждут в очереди (pending, llm_unavailable), пока администратор не исправит ключ. Может встретиться у переводов, выполненных прежней версией |
failed |
model_unavailable |
модель не выбрана или недоступна на сервере LLM |
failed |
context |
фрагмент текста не помещается в контекст модели |
failed |
invalid_output |
правильный ответ модели (не пустой, не оборванный, в формате нумерованных строк) не удалось получить ни в одной из max_attempts попыток |
failed |
max_attempts |
исчерпано число попыток перевода, или перевод 20 раз возвращался в очередь из-за сбоев сервера LLM («Перевод слишком много раз откладывался из-за сбоев сервера LLM») |
failed |
interrupted |
перевод прерван |
failed |
internal |
внутренняя ошибка перевода |
cancelled |
user или admin |
перевод отменён владельцем или администратором |
Список причин может расширяться: незнакомое значение reason обрабатывайте по его status.
Если запись на русском, но не меньше 30 % букв в ней не кириллические (смешанная речь), она всё
равно переводится — это не same_language. Причина detection_uncertain появляется, когда
автоопределение не дало уверенного ответа и запись была распознана на языке по умолчанию,
совпадающем с языком перевода: в сообщении error указан наиболее вероятный язык.
Параметры перевода для клиента
GET /translation/config
Включён ли перевод и на какой язык он выполняется — чтобы показывать или прятать переключатель перевода в своём интерфейсе. Сервер LLM при этом не опрашивается.
Ответ:
{
"enabled": true,
"target_language": "ru",
"target_language_name": "русский",
"target_language_label": "Перевести на русский",
"languages": [
{"code": "en", "name": "английский"},
{"code": "de", "name": "немецкий"}
]
}
languages — коды языков с русскими названиями (по алфавиту названий) для списков выбора языка.
Получить перевод
GET /transcriptions/{transcription_id}/translation
Параметры:
- include_content (optional):
false— без текста, только статус и прогресс (по умолчаниюtrue)
Ответ:
Объект translation. Если перевод для записи не запрашивался — 404
«Перевод не запрашивался».
Запросить перевод
POST /transcriptions/{transcription_id}/translation
Переводит уже распознанную запись или ставит перевод в очередь для записи, которая ещё распознаётся (он начнётся после распознавания). Тело запроса необязательно.
Параметры запроса:
{
"target_language": "ru",
"force": false
}
- target_language (optional): язык перевода; по умолчанию — из настроек
- force (optional): перевести заново, даже если перевод уже идёт
Ответ (202 Accepted):
Объект translation.
| Состояние перевода | Результат запроса |
|---|---|
| перевода нет | создаётся задача pending |
pending |
задача остаётся в очереди, язык перевода обновляется |
completed на тот же язык |
возвращается как есть — повторный запрос безопасен |
completed на тот же язык, force=true |
409 «Перевод уже готов» (перевести заново может администратор) |
completed на другой язык, failed, skipped, cancelled |
перевод выполняется заново |
processing |
409 «Перевод уже выполняется»; с force=true — перевод начинается заново, но израсходованные попытки сохраняются (бесконечные перезапуски закончатся failed/max_attempts); у администратора счёт попыток начинается с нуля |
Ошибки:
- 400: неподдерживаемый язык перевода
- 404: запись не найдена
- 409: перевод уже выполняется или уже готов
- 429: у пользователя уже слишком много переводов в очереди и в работе (
max_active_per_user, по умолчанию 5) - 503: «Перевод отключён администратором»
Отменить перевод
POST /transcriptions/{transcription_id}/translation/cancel
Отменяет перевод в состоянии pending или processing. Ответ 200 — объект
translation со статусом cancelled. Если перевод уже завершён — 409
«Перевод уже завершён»; если его нет — 404.
Скачать перевод
GET /transcriptions/{transcription_id}/download?format=txt&translated=true
- format:
txt— диалог по собеседникам (если запись размечена) или сплошной текст;srt— субтитры из переведённых сегментов с исходными таймкодами (400, если сегментов нет) - translated:
true— перевод;false(по умолчанию) — оригинал, как раньше - Пока перевод не завершён —
409«Перевод ещё не готов»
Примеры:
cURL:
# Состояние перевода без текста — для частого опроса
curl -H "Authorization: Bearer YOUR_API_KEY" \
"http://localhost:8020/transcriptions/<ID>/translation?include_content=false"
# Перевести уже распознанную запись
curl -X POST "http://localhost:8020/transcriptions/<ID>/translation" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"target_language": "ru"}'
# Отменить перевод
curl -X POST "http://localhost:8020/transcriptions/<ID>/translation/cancel" \
-H "Authorization: Bearer YOUR_API_KEY"
# Скачать перевод субтитрами
curl -H "Authorization: Bearer YOUR_API_KEY" \
"http://localhost:8020/transcriptions/<ID>/download?format=srt&translated=true" \
-o interview_ru.srt
Статус перевода также есть в GET /transcriptions/{id}/status (translation_status,
translation_progress, translation_error) и в списке истории (translation_status).
OpenAI-совместимый API: перевод
Перевод вместе с распознаванием
POST /v1/audio/transcriptions
Принимает те же translate_to и wait_translation, что и /transcribe/. Без translate_to
эндпоинт работает в точности как раньше.
- В
response_format=jsonиverbose_jsonдобавляется полеtranslation:transcription_id,status,reason,progress,source_language,target_language,text,error(вverbose_json— ещё иsegments). Форматыtext,srtиvttпо-прежнему содержат только оригинал. - Перевод ожидается (
wait_translation, не дольшеsync_wait_seconds) только дляjsonиverbose_json. Ответtext,srtилиvttприходит сразу после распознавания, перевод продолжается в фоне: заберите его поX-Transcription-Id—GET /transcriptions/{id}/translationилиGET /transcriptions/{id}/download?format=srt&translated=true. - Все ответы, включая ошибки после постановки задачи в очередь, содержат заголовок
X-Transcription-Id— по нему результат можно получить позже, не загружая файл повторно. Ответы с ошибкой содержатx-should-retry: false, чтобы OpenAI SDK не отправлял файл повторно. - Неподдерживаемый
translate_to—400с"param": "translate_to","code": "invalid_translate_to"(в том же формате, что и другие ошибки этого эндпоинта).
{
"text": "Hello! Thanks for coming.",
"translation": {
"transcription_id": "uuid",
"status": "completed",
"reason": null,
"progress": 100,
"source_language": "en",
"target_language": "ru",
"text": "Здравствуйте! Спасибо, что пришли.",
"error": null
}
}
Перевод речи одним запросом
POST /v1/audio/translations
Аналог эндпоинта OpenAI. Отличие от OpenAI: OpenAI переводит на английский, а этот
эндпоинт — на язык перевода из настроек (по умолчанию русский); для английского передайте
translate_to=en. Возвращает только перевод. Работает, когда перевод включён администратором,
иначе — 503 translation_disabled (до модуля перевода этого маршрута не было, ответ был 404).
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
file |
file | — | аудио или видео (mp3/mp4/mpeg/mpga/m4a/wav/webm/ogg/flac/mov/mxf) |
model |
str | whisper-1 |
игнорируется |
prompt |
str | — | игнорируется |
response_format |
str | json |
json / text / srt / vtt / verbose_json |
temperature |
float | — | игнорируется |
language |
str | auto |
язык записи (расширение) |
translate_to |
str | язык из настроек | язык перевода (расширение) |
diarize |
bool | false |
разделение по собеседникам (расширение) |
num_speakers |
int | — | точное число собеседников (расширение) |
Сервер ждёт распознавание (до QUEUE_SYNC_WAIT_SECONDS), затем перевод (до
sync_wait_seconds). Форматы ответа:
json→{"text": "перевод"}text→ перевод простым текстом (диалог по собеседникам приdiarize=true)srt/vtt→ субтитры из переведённых сегментовverbose_json→task,language(исходный язык),duration,text,segmentsиtranslation
{
"task": "translate",
"language": "en",
"duration": 4.1,
"text": "Здравствуйте! Спасибо, что пришли.",
"segments": [
{"id": 0, "start": 0.0, "end": 2.5, "text": "Здравствуйте!"},
{"id": 1, "start": 2.5, "end": 4.1, "text": "Спасибо, что пришли."}
],
"translation": {
"transcription_id": "uuid",
"status": "completed",
"reason": null,
"progress": 100,
"source_language": "en",
"target_language": "ru",
"text": "Здравствуйте! Спасибо, что пришли.",
"error": null
}
}
Если язык записи уже совпадает с языком перевода, возвращается исходный текст (диалог при
diarize=true) с исходными сегментами; если в записи нет речи — пустой текст.
Ошибки — в формате OpenAI, с заголовками x-should-retry: false и (когда задача уже создана)
X-Transcription-Id:
{
"error": {
"message": "…",
"type": "…",
"param": null,
"code": "translation_timeout"
}
}
| HTTP | code |
Когда |
|---|---|---|
| 401 | invalid_api_key |
ключ не передан, неверен или истёк — задача не создаётся |
| 400 | invalid_file_format, file_too_large, invalid_num_speakers, invalid_response_format, invalid_translate_to, invalid_language |
ошибка в запросе — файл не сохраняется, задача не создаётся |
| 400 | invalid_value, missing_required_parameter, invalid_request_body |
поле формы не разобрано (например, temperature=warm; имя поля — в param), не передан file или тело запроса не читается как форма — задача не создаётся. У /v1/audio/transcriptions такие ошибки по-прежнему отдаются как 422 FastAPI ({"detail": [...]}) |
| 503 | translation_disabled |
перевод выключен администратором. Проверяется до сохранения файла (тогда задачи нет и заголовка X-Transcription-Id нет); если перевод выключили, пока запрос ждал, — ответ с X-Transcription-Id, распознавание продолжается |
| 500 | file_processing_error |
не удалось сохранить загруженный файл — задача не создаётся |
| 500 | transcription_error |
распознавание завершилось ошибкой |
| 504 | transcription_timeout |
распознавание не уложилось в QUEUE_SYNC_WAIT_SECONDS |
| 500 | translation_error |
не удалось поставить перевод в очередь — распознавание продолжается, перевод можно запросить позже: POST /transcriptions/{id}/translation |
| 503 | translation_unavailable |
с X-Transcription-Id: сервер LLM или модель сейчас недоступны — перевод ждёт в очереди. Без заголовка: модуль перевода недоступен, запрос отклонён до сохранения файла, задача не создана |
| 502 | translation_failed |
перевод завершился ошибкой (в сообщении — текст ошибки и причина) |
| 504 | translation_timeout |
перевод не уложился в sync_wait_seconds — он продолжается, результат: GET /transcriptions/{id}/translation |
| 409 | translation_cancelled |
перевод отменил владелец или администратор, пока запрос ждал |
| 422 | translation_skipped |
язык записи не удалось определить уверенно — повторите с явным language |
| 500 | internal_error |
непредвиденная ошибка сервера; X-Transcription-Id есть, только если задача уже создана |
Ошибка с заголовком X-Transcription-Id означает, что задача создана и не потеряна (в том числе
после 503/504): не загружайте файл заново, а заберите результат по этому id через
GET /transcriptions/{id}/translation; если перевод не выполнится сам (translation_disabled,
translation_error, translation_cancelled), запросите его повторно —
POST /transcriptions/{id}/translation. Ошибка без X-Transcription-Id (400, 401,
file_processing_error, а также 503 до сохранения файла) задачу не создала: запрос можно
повторить.
Примеры:
cURL:
curl -X POST "http://localhost:8020/v1/audio/translations" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@interview_en.mp3" \
-F "response_format=srt" \
-D headers.txt \
-o interview_ru.srt
Python (OpenAI SDK):
from openai import OpenAI
client = OpenAI(
api_key="sk-...", # API-ключ Speech2Text
base_url="http://localhost:8020/v1",
timeout=4200, # распознавание (до 3600 с) + перевод (до 600 с)
max_retries=0, # повтор отправил бы файл ещё раз и создал бы вторую задачу
)
# Только перевод: /v1/audio/translations
with open("interview_en.mp3", "rb") as f:
result = client.audio.translations.create(
model="whisper-1",
file=f,
response_format="verbose_json",
extra_body={"language": "auto", "translate_to": "ru"},
)
print(result.text) # текст на русском
# Оригинал и перевод одним запросом: /v1/audio/transcriptions
with open("interview_en.mp3", "rb") as f:
transcript = client.audio.transcriptions.create(
model="whisper-1",
file=f,
response_format="json",
extra_body={"language": "auto", "translate_to": "ru"},
)
print(transcript.text) # оригинал
translation = getattr(transcript, "translation", None) # расширение, нет в схеме OpenAI
if translation and translation["status"] == "completed":
print(translation["text"])
Поля, которых нет в OpenAI SDK (language=auto, translate_to, wait_translation, diarize),
передаются через extra_body. Параметры timeout и max_retries важны: ответ может прийти
через десятки минут, а повтор по таймауту загрузил бы файл ещё раз.
Настройки автоопределения (/admin/system/language-detection)
Пороги автоопределения языка (language=auto) задаются в админ-панели: Система →
Автоопределение языка. Они хранятся в базе данных (app_settings, ключ language_detection)
и переопределяют значения из .env: поле, не заданное в админ-панели, берётся из .env.
Воркеры Whisper перечитывают настройки не реже раза в 5 секунд — перезапуск не нужен. Если база
данных недоступна, используются последние прочитанные значения, а без них — значения .env.
Язык записей, загруженных без language, по-прежнему задаёт только WHISPER_DEFAULT_LANGUAGE
в .env. Эндпоинты доступны только администратору с токеном входа (JWT); с API-ключом sk-…
они отвечают 403 «Только из админ-панели».
| Параметр | По умолчанию (.env) |
Допустимые значения | Назначение |
|---|---|---|---|
min_probability |
LANGUAGE_DETECT_MIN_PROBABILITY (0.5) |
0–1 | минимальная уверенность определения |
min_margin |
LANGUAGE_DETECT_MIN_MARGIN (0) |
0–1 | минимальный отрыв самого вероятного языка от второго; 0 — не проверяется |
fallback_language |
WHISPER_DEFAULT_LANGUAGE (ru) |
код или название языка Whisper | язык распознавания, если определение ненадёжно, в записи тишина или определить язык не удалось |
Определение ниже min_probability или с отрывом меньше min_margin отмечается в
language_detection как "fallback": true, "fallback_reason": "low_confidence".
GET /admin/system/language-detection — действующие значения, значения из .env
(defaults) и список переопределённых полей (overridden):
{
"min_probability": 0.5,
"min_margin": 0.0,
"fallback_language": "ru",
"fallback_language_name": "русский",
"defaults": {"min_probability": 0.5, "min_margin": 0.0, "fallback_language": "ru"},
"overridden": [],
"default_language": "ru",
"updated_at": null,
"updated_by": null,
"cache_ttl_seconds": 5.0,
"languages": [{"code": "en", "name": "английский"}, {"code": "de", "name": "немецкий"}]
}
PUT /admin/system/language-detection — передаются только изменяемые поля; null
возвращает поле к значению из .env. Ответ — как у GET. Ошибки: 400 с описанием неверного
поля; 400 «Не передано ни одной настройки».
curl -X PUT "http://localhost:8020/admin/system/language-detection" \
-H "Authorization: Bearer ADMIN_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"min_probability": 0.6, "min_margin": 0.1}'
Настройки перевода (/admin/system/translation)
Все настройки задаются в админ-панели: Система → Перевод (LLM). Они хранятся в базе данных
(не в .env) и применяются без перезапуска сервиса. Эндпоинты этого подраздела доступны только
администратору с токеном входа (JWT); с API-ключом sk-… они отвечают 403
«Только из админ-панели».
| Параметр | По умолчанию | Допустимые значения | Назначение |
|---|---|---|---|
enabled |
false |
true / false |
модуль перевода включён |
base_url |
http://127.0.0.1:1234/v1 |
http/https, путь заканчивается на /v1, без логина и пароля в адресе, без ? и # |
адрес OpenAI-совместимого сервера LLM, например http://<llm-host>:1234/v1 |
api_key |
пусто | до 500 символов | ключ сервера LLM (передаётся как Authorization: Bearer); пусто — без ключа |
model |
qwen/qwen3.6-35b-a3b |
до 200 символов | id модели на сервере LLM (по умолчанию — Qwen3.6-35B-A3B под её id в LM Studio); пусто — автоматически: единственная загруженная LLM/VLM на сервере LM Studio |
target_language |
ru |
код языка Whisper | язык перевода по умолчанию |
concurrency |
2 |
1–8 | сколько записей переводится одновременно |
timeout_seconds |
180 |
10–600 | таймаут одного запроса к LLM, сек |
max_tokens |
8192 |
256–131072 | верхний предел max_tokens одного запроса |
temperature |
0.2 |
0–2 | температура генерации |
chunk_chars |
3000 |
300–8000, причём chunk_chars × 0.6 ≤ max_tokens |
размер фрагмента текста на один запрос, символов (иероглифы и тайский считаются за 2,5) |
reasoning_effort |
none |
none, minimal, low, medium, high или пусто |
режим рассуждений модели; пусто — параметр не передаётся |
skip_same_language |
true |
true / false |
не переводить, если язык записи уже совпадает с языком перевода |
sync_wait_seconds |
600 |
0–3600 | сколько синхронные API ждут перевод, сек |
callback_wait_seconds |
3600 |
0–86400 | сколько callback_url задачи с translate_to ждёт перевод после распознавания, сек (раздел 8); 0 — не ждать |
defer_while_busy |
false |
true / false |
не переводить, пока Whisper распознаёт записи. Включайте, только если LLM и Whisper делят одну видеокарту |
defer_max_minutes |
30 |
0–1440 | после стольких минут ожидания перевод всё же идёт (по одной записи); 0 — ждать без ограничения |
allow_model_load |
false |
true / false |
разрешить серверу LLM загружать модель по запросу (JIT) |
extra_prompt |
пусто | до 2000 символов, можно в несколько строк | дополнительные инструкции переводчику: глоссарий, стиль |
max_attempts |
3 |
1–10 | сколько попыток даётся одному переводу |
max_active_per_user |
5 |
1–100 | сколько переводов обычного пользователя может быть в очереди и в работе; проверяется только при явном запросе POST /transcriptions/{id}/translation, на загрузки и администратора не действует |
Строки, содержащие ${, не принимаются.
Получить настройки
GET /admin/system/translation
Все параметры, кроме ключа, плюс api_key_set (ключ задан) и api_key_hint (последние
4 символа ключа, например …abcd; для ключей короче 8 символов — пусто), состояние модуля
status и max_concurrency. Сам ключ
API никогда не возвращает. Сервер LLM при этом не опрашивается.
{
"enabled": true,
"base_url": "http://<llm-host>:1234/v1",
"model": "qwen/qwen3.6-35b-a3b",
"target_language": "ru",
"concurrency": 2,
"timeout_seconds": 180,
"max_tokens": 8192,
"temperature": 0.2,
"chunk_chars": 3000,
"reasoning_effort": "none",
"skip_same_language": true,
"sync_wait_seconds": 600,
"callback_wait_seconds": 3600,
"defer_while_busy": false,
"defer_max_minutes": 30,
"allow_model_load": false,
"extra_prompt": "",
"max_attempts": 3,
"max_active_per_user": 5,
"api_key_set": true,
"api_key_hint": "…abcd",
"status": {"enabled": true, "running": true, "in_flight": 1, "waiting_transcription": 0}, // сокращено
"max_concurrency": 8
}
status — счётчики переводов по статусам, число ждущих распознавания и выполняющихся сейчас,
возраст самого старого перевода в очереди, работает ли обработчик очереди, а также состояние
сервера LLM: время последнего успеха и последней ошибки, задержка ответа, пауза после сбоев
(paused_until) и проверка модели (model_state).
Изменить настройки
PUT /admin/system/translation
Передаются только изменяемые параметры. api_key: не передан, null, пустая строка или строка
из одних пробелов — ключ не меняется; "clear_api_key": true удаляет сохранённый ключ. Ответ — как у GET.
Ошибки: 400 с описанием первого неверного параметра; 400 «Не передано ни одной настройки».
curl -X PUT "http://localhost:8020/admin/system/translation" \
-H "Authorization: Bearer ADMIN_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "base_url": "http://<llm-host>:1234/v1", "model": "qwen/qwen3.6-35b-a3b", "reasoning_effort": "none"}'
Список моделей сервера LLM
POST /admin/system/translation/models
Тело (все поля необязательны): {"base_url": "...", "api_key": "...", "clear_api_key": false} —
позволяет проверить адрес до сохранения настроек.
{
"ok": true,
"lmstudio": true,
"models": [
{"id": "qwen/qwen3.6-35b-a3b", "type": "vlm", "loaded": true, "context_length": 65536},
{"id": "text-embedding-nomic-embed-text-v1.5", "type": "embeddings", "loaded": false, "context_length": null}
],
"error": null
}
lmstudio: true — сервер отвечает на LM Studio API, и известно, какие модели загружены. У
других серверов loaded равно null.
Проверить соединение
POST /admin/system/translation/test
Тело (все поля необязательны): {"base_url", "api_key", "clear_api_key", "model", "allow_load",
"allow_model_load", "reasoning_effort", "temperature", "target_language"} — текущие (в том числе
несохранённые) значения формы. Не переданные поля (или null) берутся из сохранённых настроек.
"allow_model_load": false действует и тогда, когда в сохранённых настройках загрузка по запросу
включена: незагруженная модель в таком случае не используется (ответ с needs_load). Сохранённый ключ отправляется только на тот
же сервер (схема, хост и порт совпадают с сохранённым адресом) — при проверке другого адреса
ключ нужно указать явно.
Если модель на сервере не загружена, проверка не отправляет текст модели (иначе LM Studio
загрузил бы её сам) и возвращает ok: false, model_loaded: false, needs_load и
suggestions — похожие загруженные модели. "allow_load": true разрешает загрузить модель
ради проверки: она займёт видеопамять сервера LLM. Если модель доступна, сервер переводит две
тестовые строки и возвращает результат:
{
"ok": true,
"models": [{"id": "qwen/qwen3.6-35b-a3b", "type": "vlm", "loaded": true, "context_length": 65536}],
"model": "qwen/qwen3.6-35b-a3b",
"model_found": true,
"model_loaded": true,
"needs_load": false,
"suggestions": [],
"served_model": "qwen/qwen3.6-35b-a3b",
"reply": "Здравствуйте! Это проверка соединения. Спасибо.",
"latency_ms": 840,
"reasoning_tokens": 0,
"protocol_ok": true,
"warnings": [],
"error": null
}
served_model— модель, которая фактически ответила; если она отличается от запрошенной, вwarningsбудет предупреждениеprotocol_ok— модель соблюла формат нумерованных строк, на котором построен переводreasoning_tokens > 0приreasoning_effort: "none"— сервер не отключил рассуждения (предупреждение вwarnings)error— понятное описание проблемы без технических подробностей
Переводы (Admin)
Доступны администратору (JWT или API-ключ администратора) для записей любых пользователей.
| Метод | Путь | Описание |
|---|---|---|
| GET | /admin/transcriptions/{id}/translation?include_content=true |
перевод записи, с полями error_detail, attempts, usage, source |
| POST | /admin/transcriptions/{id}/translation |
запросить перевод: тело {"target_language", "force"}, ответ 202. Без лимита на пользователя; force=true переводит заново и готовый перевод |
| POST | /admin/transcriptions/{id}/translation/cancel |
отменить перевод, ответ 200 |
| GET | /admin/translations/status?ids=a,b,c |
краткие статусы до 100 записей: {id: {status, reason, progress, waiting_for_transcription}} |
| GET | /admin/transcriptions/{id}/download?format=srt&translated=true |
скачать оригинал или перевод любой записи (format: txt или srt) |
| GET | /admin/transcriptions/{id} |
в ответе добавлены translation и language_detection |
| GET | /admin/transcriptions |
в каждой строке добавлен translation_status |
| GET | /admin/queue |
добавлен блок translation: stats, items (переводы в очереди и в работе, до 50, старые первыми) и recent_failures (10 последних сбоев); null, если собрать его не удалось |
Prometheus (GET /metrics/prometheus) дополнительно отдаёт метрики без меток:
speech2text_translation_pending, _processing, _completed, _failed, _skipped,
_in_flight, speech2text_translation_enabled, _dispatcher_running, _llm_up (1 — перевод
включён, пауза после сбоев не активна и последняя проверка сервера LLM и модели прошла; 0 —
перевод выключен, сервер ещё не проверялся с момента запуска (проверка идёт, когда есть работа)
или проверка не прошла), _oldest_pending_age_seconds, _last_success_timestamp_seconds.
Как перевод бережёт распознавание
Защита от загрузки моделей (JIT). LM Studio загружает в видеопамять любую модель, имя
которой пришло в запросе, и может ради неё выгрузить уже работающую. Если LLM и Whisper делят
видеокарты, такая подмена замедлит или сорвёт распознавание. Поэтому перед отправкой текста
модуль спрашивает у LM Studio список загруженных моделей (GET /api/v0/models, результат
кешируется на 30 секунд) и обращается только к загруженной модели:
- По умолчанию
model=qwen/qwen3.6-35b-a3b— так Qwen3.6-35B-A3B называет LM Studio. Если на сервере LLM модель называется иначе (например,qwen3.6-35b-a3bбез издателя), переводы ждут в очереди с ошибкой «Модели … нет на сервере LLM — выберите загруженную модель в настройках перевода», а «Проверка соединения» предлагает похожую загруженную модель (кнопка «Использовать …»). Сам модуль другую модель вместо заданной не подставляет. modelпуста — используется единственная загруженная LLM. Если не загружено ни одной, переводы ждут в очереди, попытки не расходуются. Если загружено несколько — перевод не начинается, а в админ-панели видна ошибка «На сервере LLM загружено несколько моделей — выберите модель в настройках».modelзадана, но не загружена — переводы ждут в очереди, пока модель не загрузят вручную; сам модуль её не загружает. Параметрallow_model_loadснимает этот запрет — не включайте его, если видеокарты общие с Whisper.- Серверы без LM Studio API (vLLM, Ollama и т. п.) определяются только по чистому ответу
404/405 на
GET /api/v0/models. Для них модель нужно указать явно; если сервер отдаёт список моделей (GET /v1/models), она должна в нём быть — иначе переводы ждут в очереди. - Проверка «закрыта по умолчанию»: если LM Studio API не ответил (таймаут, ошибка 5xx, 401/403 от прокси, неразборчивый ответ) или сервер, который раньше отвечал как LM Studio, вдруг ответил 404, состояние моделей считается неизвестным и переводы ждут, попытки не расходуются.
- Загрузка модели перепроверяется перед каждым фрагментом и перед повтором запроса после сбоя. Если модель выгрузили посреди перевода или в настройках сменили сервер или модель, перевод возвращается в очередь и начнётся заново, когда модель снова будет загружена.
- Если сервер отвечает на перевод кодом 401/403 (неверный API-ключ), 404/405 или сообщает, что
модели не существует, очередь переводов приостанавливается (переводы не помечаются ошибкой и
ждут в
pending, причинаllm_unavailable): до изменения адреса, ключа или модели, успешной «Проверки соединения» в админ-панели или пробной задачи раз в 5 минут.
Пауза, пока Whisper занят (defer_while_busy, по умолчанию выключено). Когда параметр
включён, то пока хотя бы один воркер Whisper распознаёт запись, новые переводы не начинаются, а
начатые приостанавливаются между фрагментами. Если пауза длится дольше defer_max_minutes,
переводы продолжаются по одному. Включайте его, только если LLM и Whisper делят одну видеокарту:
на отдельной видеокарте или отдельном сервере пауза лишь задерживает переводы.
Пауза после сбоев. Если сервер LLM недоступен (нет соединения, таймаут, ответы 429, 502,
503, 504, модель загружается), перевод возвращается в очередь (pending, причина
llm_unavailable), а модуль делает паузу 15 → 30 → 60 → 120 → 300 секунд; после первого
успешного перевода пауза сбрасывается. Ошибка соединения, ответ 503 и незагруженная модель
попытку не расходуют, но такие возвраты считаются (usage.requeues в административных
эндпоинтах): после 20 возвратов перевод завершается со статусом failed (max_attempts,
«Перевод слишком много раз откладывался из-за сбоев сервера LLM»). Таймауты, прочие ошибки 5xx и
429 попытку расходуют, и после max_attempts попыток перевод завершается со статусом failed
(max_attempts).
Плохой ответ модели. Пустой ответ, ответ не в формате нумерованных строк или ответ, оборванный
лимитом токенов (модель зациклилась), — проблема одного перевода, а не сервера: очередь не
приостанавливается. Сначала запрос сразу повторяется (с повышенной температурой, при
необходимости — по частям). Если и это не помогло, перевод возвращается в очередь и повторяется
через 15 с × номер попытки (пауза хранится в памяти процесса: после перезапуска повтор начнётся
сразу), причём на второй попытке фрагменты вдвое, а на третьей и дальше — вчетверо меньше (но не
меньше 300 символов). После max_attempts попыток перевод завершается со статусом failed
(invalid_output).
Выключение на ходу. Если администратор выключает перевод, начатые переводы возвращаются в очередь (попытка не расходуется) и продолжатся после повторного включения.
Как переводится текст. Расшифровка переводится фрагментами (по умолчанию до 3000 символов и
не больше 80 сегментов): каждый сегмент — отдельная нумерованная строка, поэтому таймкоды и
собеседники перевода совпадают с оригиналом один к одному. Модель получает контекст — последние
строки предыдущего фрагмента и их перевод, а в диалогах — метки собеседников, чтобы сохранять род
и форму обращения. Если ответ модели оборван или не разобран, фрагмент переводится повторно, при
необходимости — по частям. При переводе на русский строки, оставшиеся без перевода, переводятся
ещё раз отдельно; если эта попытка не удалась, готовый перевод сохраняется как есть. Пустые
сегменты оригинала остаются в segments перевода с пустым текстом: число, порядок и id
сегментов перевода всегда совпадают с оригиналом. Числа модель пишет цифрами, сохраняя значение и
порядок величины; если в тексте есть китайские или японские числительные 万 и 亿, в инструкцию
модели добавляется подсказка об их значении.
Рассуждения модели. Qwen3.6 по умолчанию «рассуждает» перед ответом — это медленнее и
расходует токены. Значение reasoning_effort: "none" (по умолчанию) отключает рассуждения.
8. Загрузка по ссылке и обратный вызов
Оба эндпоинта транскрибации — POST /transcribe/ и POST /transcribe/async — принимают два
дополнительных поля формы: url (откуда взять аудио) и callback_url (куда отдать результат).
Вместе они закрывают сценарий «отдал ссылку — забыл»: клиенту не нужно ни держать соединение с
файлом на сотни мегабайт, ни опрашивать статус.
Ссылка вместо файла (url)
curl -X POST "http://localhost:8020/transcribe/async" \
-H "Authorization: Bearer sk-XXXXXXXX..." \
-F "url=https://example.com/records/call-2026-09-03.mp3" \
-F "language=ru"
Правила:
- Источник должен быть ровно один — либо
file, либоurl. Пустая строка вurlи часть формы с пустым именем файла считаются «не задано», так что клиент может слать оба поля всегда. Если задано и то и другое (или ничего) —400:Укажите ровно один источник: файл (file) или ссылку (url). - Схема — только
http://илиhttps://. POST /transcribe/скачивает файл внутри этого запроса, потоком: недоступная ссылка — это ошибка сразу, а не упавшая через десять минут задача. Файл кладётся вuploads/temp, как и обычная загрузка.POST /transcribe/asyncотвечает202до скачивания: до ответа проверяется только сама ссылка (схема, корректность, хост, резолв, политика хостов), а файл сервис скачивает в фоне — в тот жеuploads/temp. Клиенту не нужно держать соединение, пока едут гигабайты, а обрыв соединения или таймаут на его стороне больше не лишают егоidуже созданной задачи. Неудача скачивания — это не код ответа, а задача в статусеfailed(см. ниже).- Лимит размера тот же, что у загрузки файлом —
MAX_FILE_SIZE_MB(по умолчанию 5000 МБ; поднят с 500 МБ ради видеоконтейнеров, которые весят гигабайты). Он проверяется и по заявленномуContent-Length, и по мере прихода байтов: соврать в заголовке не поможет. - Имя файла берётся из
Content-Disposition, иначе из последнего сегмента пути конечного адреса (после редиректов). Расширение проверяется так же строго, как у загруженного файла (.mp3,.mp4,.wav,.m4a,.ogg,.flac,.mp2,.mov,.mxf,.webm). В/transcribe/при несовпадении —400с перечислением допустимых, а скачанный файл сразу удаляется. В/transcribe/asyncтип проверяется по заголовкам ответа, до чтения тела (файл вообще не скачивается), и задача переходит вfailed. - У задачи
/transcribe/async, чей файл ещё скачивается,filenameпредварительный — последний сегмент пути самой ссылки (илиaudio, если его нет); после скачивания его заменяет имя по правилу выше. - Ссылка на видео — это нормально:
.mov,.mxf,.webmи.mp4скачиваются целиком, а звук из них извлекает сервис (несколько дорожек при этом сводятся в одну — см. «Что можно загружать»). FETCH_TIMEOUT_SEC(по умолчанию 120 с) — в/transcribe/абсолютный дедлайн на всё скачивание, а не таймаут одного чтения: «капающий» источник тоже обрывается. В обоих эндпоинтах это ещё и предел молчания: источник, который столько не присылает ни байта, обрывается.- Фоновое скачивание
/transcribe/asyncограничено своим абсолютным дедлайномFETCH_BACKGROUND_TIMEOUT_SEC(по умолчанию 3600 с): его никто не ждёт на открытом соединении, поэтому он рассчитан на самый большой файл, который пропускаетMAX_FILE_SIZE_MB. Одновременно идёт не большеFETCH_MAX_CONCURRENT(по умолчанию 4) таких скачиваний, причём задачи одного пользователя занимают не больше половины мест (не меньше одного); остальные ждут свободного места, и их дедлайн отсчитывается с момента, когда скачивание началось. Источник, который слишком медленно отдаёт заголовки (дольшеFETCH_TIMEOUT_SEC) или тело (в среднем меньше 8 КБ/с после первой минуты), обрывается — иначе он держал бы место час.
Ошибки скачивания в POST /transcribe/ — код ответа на сам запрос:
| Код | Когда |
|---|---|
400 |
Не та схема, битый URL, 4xx от источника, пустой ответ, неподдерживаемое расширение |
403 |
Адрес запрещён политикой хостов (см. предупреждение ниже) |
413 |
Аудио больше MAX_FILE_SIZE_MB |
502 |
Источник недоступен или ответил 5xx |
504 |
Источник не уложился в FETCH_TIMEOUT_SEC |
POST /transcribe/async до ответа отказывает кодом только из-за самой ссылки (и прочих полей
запроса — translate_to, callback_url, числа собеседников):
| Код | Когда |
|---|---|
400 |
Заданы оба источника или ни одного; не та схема, битый URL, нет хоста, хост не резолвится |
403 |
Адрес запрещён политикой хостов (см. предупреждение ниже) |
Всё остальное выясняется уже после ответа 202. Задача переходит в failed, а в error_message
(GET /transcriptions/{id}/status, очередь в админ-панели) записывается
Не удалось скачать файл по ссылке: <причина>; ссылки в причине — с вырезанными секретами, как в
source_url:
| Что случилось | Причина после двоеточия (пример) |
|---|---|
Источник ответил 4xx или 5xx |
https://example.com/records/call.mp3 returned HTTP 404 |
| Источник недоступен (соединение, TLS) | Could not fetch https://example.com/records/call.mp3: ConnectError: … |
| Пустой ответ | https://example.com/records/call.mp3 returned an empty file |
Больше MAX_FILE_SIZE_MB |
The file is 6144 MB, over the 5000 MB limit (по Content-Length) или The file exceeds 5000 MB |
| Дедлайн | Timed out fetching … after 3600s (FETCH_BACKGROUND_TIMEOUT_SEC), … (no data for 120s) (источник молчит дольше FETCH_TIMEOUT_SEC), … while connecting |
Заголовки ответа не пришли за FETCH_TIMEOUT_SEC |
https://example.com/records/call.mp3 sent no response within 120s |
| Источник слишком медленный (в среднем меньше 8 КБ/с после первой минуты) | https://example.com/records/call.mp3 is too slow: 300 KB in 61s, under 8 KB/s |
| Неподдерживаемый тип файла | Тип файла по ссылке не поддерживается (.html). Допустимые: … |
| Редирект на запрещённый адрес | Host '…' resolves to the non-public address … — refused (FETCH_ALLOW_PRIVATE_HOSTS is off) |
| Слишком много редиректов | https://example.com/records/call.mp3 redirected more than 5 times |
Что ещё происходит при неудачном скачивании:
- Если заказан
callback_url, приходит обычный вызовtranscription.failed— возможно, уже через несколько секунд после ответа202(см. «Обратный вызов»). - Если заказан перевод (
translate_to), он закрывается какskippedс причинойtranscription_failed— в тот же момент (одной транзакцией), когда задача становитсяfailed, так что ни/status, ни тело вызова не покажут перевод, «застрявший» вpending. - Вернуть такую задачу в очередь из админ-панели нельзя (
409): файла у неё нет. Отправьте задачу заново.
Перезапуск, отмена, удаление. Если сервис перезапускается во время скачивания, оно
прерывается (недокачанный файл удаляется), а при следующем старте начинается заново, с начала
— докачки нет. Presigned-ссылка, истёкшая за это время, даёт failed с HTTP-ошибкой источника
(обычно 403). Раз в 5 минут сервис также сверяет задачи, ждущие файла, с идущими скачиваниями и
перезапускает потерянные. Отмена задачи администратором, DELETE /transcriptions/{id} и удаление
пользователя администратором останавливают скачивание, и задача больше не оживает.
⚠️ Внутренняя сеть. По умолчанию
FETCH_ALLOW_PRIVATE_HOSTS=false: адреса из приватных, loopback- и link-local-диапазонов, а также из диапазона операторского NAT100.64.0.0/10(CGNAT, Tailscale) запрещены и как источник (url), и как приёмник (callback_url). Проверяется каждый редирект, а имя резолвится один раз, и соединение идёт именно по проверенному адресу — иначе смена DNS между проверкой и подключением обходила бы политику. ВключённыйFETCH_ALLOW_PRIVATE_HOSTS=trueпревращает любой API-ключ в HTTP-прокси внутрь вашего периметра: держатель ключа сможет заставить сервис ходить по внутренним адресам. Включайте, только если держатели ключей и так имеют доступ в эту сеть — например, когда записи лежат на файловом сервере в LAN.
source_url сохраняется и отдаётся с вырезанными секретами: ?X-Amz-Signature=…, ?token=…,
https://user:pass@host/… и подобное заменяется на REDACTED. Presigned-ссылке нечего делать ни
в истории, ни в теле обратного вызова на чужом приёмнике. Для фонового скачивания
/transcribe/async полная ссылка (с подписью) хранится во внутреннем поле базы только до конца
скачивания — чтобы повторить его после перезапуска — и не отдаётся ни одним ответом API и ни в
одном обратном вызове.
Обратный вызов (callback_url)
curl -X POST "http://localhost:8020/transcribe/async" \
-H "Authorization: Bearer sk-XXXXXXXX..." \
-F "url=https://example.com/records/call.mp3" \
-F "callback_url=https://example.com/hooks/asr.php" \
-F "language=ru" \
-F "return_segments=true"
Адрес проверяется сразу при постановке (та же политика хостов, что и для url): опечатка —
это 400 с телом {"detail": "callback_url: ..."} на том запросе, который её сделал, а не
молчаливый провал через десять минут.
Когда задача доходит до конечного состояния — completed, failed или cancelled — сервис
отправляет POST:
POST /hooks/asr.php HTTP/1.1
Content-Type: application/json; charset=utf-8
X-ASR-Task-Id: 9191b72b-23d1-4bc8-b2e9-24075e66a5af
X-ASR-Event: transcription.completed
User-Agent: Speech2Text ASR callback
Неудачное скачивание по url в /transcribe/async — тоже конечное состояние: задача failed, и
приходит вызов с X-ASR-Event: transcription.failed. Причины в теле вызова нет (как и у любой
другой неудачи — поля error_message в нём нет): её отдаёт GET /transcriptions/{id}/status
(«Не удалось скачать файл по ссылке: …»). Такой вызов может прийти через несколько секунд после ответа
202 — возможно, раньше, чем клиент успел сохранить id задачи. Приёмник должен принимать
вызов с ещё незнакомым ему id, а не отвергать его: ответ не-2xx лишь запустит повторы (см.
«Повторы» ниже).
Тело — тот же JSON, что отдаёт GET /transcriptions/{id} (text, language, duration,
segments, diarization, поля доставки). Поля модуля перевода translation и
language_detection в теле вызова есть, только когда у них есть значение (перевод заказан,
язык определялся при language=auto): у задачи без перевода тело такое же, как до модуля,
и приёмник со строгой схемой (без лишних ключей) продолжает его принимать. Приёмник получает
его в php://input — в $_POST его не будет, это JSON, а не форма.
Подпись вызова. Если у API-ключа, создавшего задачу, задано секретное слово и включён
sign_callbacks, к заголовкам добавляются X-Webhook-Timestamp и X-Webhook-Signature —
см. раздел 9. Ключ без секрета или с выключенным sign_callbacks
шлёт вызовы без этих заголовков, как раньше.
Вызов и перевод (LLM). Если задача загружена с translate_to, вызов ждёт и перевода: тело
уходит, когда перевод в конечном состоянии (completed / failed / skipped / cancelled),
и поле translation в нём уже содержит переведённый текст. Ждёт не дольше
callback_wait_seconds (настройка перевода в админ-панели, «Ожидание перевода для callback»,
по умолчанию 3600 с) после завершения распознавания — дальше вызов уходит с
translation.status = "pending", а перевод забирается через
GET /transcriptions/{id}/translation. Задачи failed/cancelled не ждут никогда; пока перевод
выключен в админ-панели или callback_wait_seconds = 0, вызов не ждёт его вовсе.
Размер тела. С переводом тело вызова растёт: к оригиналу добавляются translation.text,
translation.segments и translation.text_with_speakers — у длинной иноязычной записи тело
может стать в 2–3 раза больше. Своего ограничения размера у отправителя нет. Если приёмник
ограничивает размер запроса (например, nginx по умолчанию — client_max_body_size 1m), поднимите
лимит; при ответе 413 в callback_error указывается размер отправленного тела.
Повторы. Доставкой занимается отдельный фоновый поток, который раз в
CALLBACK_POLL_SECONDS (по умолчанию 2 с) забирает готовые задачи с заказанным вызовом.
Ответ 2xx — доставлено. Иначе делается до CALLBACK_RETRIES повторов (по умолчанию 3) с
паузами 2 / 10 / 60 секунд, то есть всего до четырёх попыток; после этого
callback_status становится failed, а причина остаётся в callback_error (из тела ответа
приёмника читается не больше 2 КБ). Таймаут одной попытки — CALLBACK_TIMEOUT_SEC (30 с).
Неудачная доставка никогда не меняет статус самой задачи: аудио распознано, текст лежит в
базе и доступен по GET /transcriptions/{id}, доставка — отдельное дело.
Редиректы не выполняются. Ответ 3xx от приёмника — это неудача с понятным текстом
(callback_url redirected to '…'; redirects are not followed — register the final URL), а не
переход: 301/302 молча превратили бы POST в GET без тела, а 307 отправил бы всю
транскрипцию на хост, который политику хостов не проходил. Регистрируйте конечный адрес.
Адрес проверяется дважды — при постановке и ещё раз непосредственно перед каждой отправкой: между ними проходят минуты, и имя за это время могло быть перенаправлено.
Если сервис перезапустился, пока доставка была в полёте, вызовы, застрявшие в sending,
возвращаются в pending при старте и уходят заново — «в процессе отправки» не остаётся навсегда.
Ход доставки виден в GET /transcriptions/{id}:
| Поле | Значение |
|---|---|
callback_status |
null (вызов не заказан) · pending · sending · delivered · failed |
callback_attempts |
Сколько попыток сделано |
callback_error |
Причина последней неудачи |
callback_delivered_at |
Когда доставлено (UTC) |
Приёмник должен быть готов к повтору уже доставленного вызова (сеть могла оборвать ответ):
идентификатор задачи в X-ASR-Task-Id и поле id в теле — то, по чему дубль опознаётся.
9. Подпись HMAC-SHA256
Помимо Bearer-ключа сервис умеет подписывать трафик по схеме HMAC-SHA256 + Base64. Подпись
настраивается поключево: владелец задаёт секретное слово конкретному API-ключу и независимо
включает два выключателя.
| Выключатель | Поле ключа | Что делает |
|---|---|---|
| Подписывать callback | sign_callbacks |
Сервис добавляет X-Webhook-Timestamp / X-Webhook-Signature к исходящему POST на callback_url, чтобы приёмник мог убедиться: вызов пришёл именно отсюда |
| Требовать подпись запросов | require_request_signature |
Сервис отвергает неподписанные запросы этим ключом: украденный ключ без секретного слова бесполезен |
ℹ️ Оба выключателя по умолчанию выключены и независимы друг от друга. Обновление ничего не меняет для существующих интеграций: пока ключу не задано секретное слово и не включён соответствующий флаг, ни один запрос и ни один callback не требует подписи. Включить подпись, не задав секрет, нельзя — API управления ключами вернёт
400.
Секретное слово
Секрет привязан к API-ключу, а не к сервису целиком: у каждой интеграции он свой, и компрометация одной не затрагивает остальных. Задать его можно двумя способами:
- своё слово — от 8 до 256 символов (длина вне диапазона отклоняется валидацией,
422); - сгенерированное — сервис выдаёт значение вида
whsec_+ 43 случайных символа (generate_webhook_secret: trueили кнопка «Сгенерировать» в админ-панели).
Наружу секрет отдаётся только владельцу ключа: в списке видно лишь webhook_secret_set и маску
webhook_secret_hint (whsec_••••••••), полное значение возвращает
GET /api-keys/{key_id}/webhook-secret. В логи и в тексты ошибок
секрет не попадает никогда.
Секрет хранится в базе в открытом виде — иначе им нельзя было бы считать HMAC: это ключ подписи, а не пароль, и из хеша его не восстановить.
Заголовки
| Заголовок | Направление | Значение |
|---|---|---|
X-Signature-Timestamp |
клиент → сервис | Unix-время в секундах (целое) на момент отправки запроса |
X-Signature |
клиент → сервис | base64(HMAC-SHA256(timestamp + "." + МЕТОД + "." + путь, секрет)) |
X-Webhook-Timestamp |
сервис → приёмник callback | Unix-время в секундах на момент этой попытки доставки |
X-Webhook-Signature |
сервис → приёмник callback | base64(HMAC-SHA256(id задачи + "." + timestamp, секрет)) |
Обе схемы — одна и та же операция: HMAC-SHA256 от строки, ключ — секретное слово в UTF-8, результат — Base64 от сырых байт дайджеста (не от hex-строки). Различаются только подписываемые строки.
Схема 1. Входящий запрос
Подписывается строка
<timestamp>.<МЕТОД>.<путь со строкой запроса>
<timestamp>— ровно то же число, что уходит в заголовкеX-Signature-Timestamp;<МЕТОД>— HTTP-метод заглавными буквами (POST,GET,DELETE);<путь со строкой запроса>— путь запроса и, если строка запроса непуста,?и она же — байт в байт, в том же percent-encoding, в каком запрос реально уходит на сервер.
Пример подписываемой строки для POST /transcribe/async:
1769670760.POST./transcribe/async
и для GET /transcriptions/history?skip=0&limit=50:
1769670760.GET./transcriptions/history?skip=0&limit=50
⚠️ Путь и строка запроса подписываются «как есть». Сервис берёт их из самой строки запроса HTTP, в неразобранном виде; заголовок
Hostна подпись не влияет. URL нужно собрать самому и передать библиотеке готовым: если отдать параметры отдельным словарём (params=вrequests,http_build_queryв PHP), библиотека может закодировать их иначе, чем вы при подписи, — и подпись не сойдётся, хотя адрес выглядит тем же.
Проверка выполняется там же, где проверяется API-ключ: после проверки is_active и срока
действия и до обновления last_used_at. Запрос, отклонённый по подписи, не считается
использованием ключа.
Требование действует на все эндпоинты, которые авторизуются обычным путём: /transcribe/,
/transcribe/async, /transcriptions/* (включая /transcriptions/{id}/translation), /translation/config, /api-keys/*, /metrics/user, /auth/me. Сессия
администратора (JWT) подписи не требует никогда — выключатель относится к ключу, а не к
пользователю. OpenAI-совместимые /v1/... проверяют ключ собственным кодом и подпись не
требуют (в том числе /v1/audio/translations модуля перевода): если ключ используется и там, помните, что этот путь остаётся без подписи.
Тело запроса в подпись не входит: загрузка может достигать MAX_FILE_SIZE_MB (5000 МБ), и
буферизовать её ради HMAC сервис не станет. Подпись подтверждает, что запрос выпущен держателем
секрета, и привязывает его к методу, адресу и моменту времени; целостность содержимого
обеспечивает TLS.
Ошибки 401
| Тело | Когда |
|---|---|
{ "detail": "API key requires signed requests but has no signing secret configured" } |
Флаг включён, а секретного слова у ключа нет (штатным путём такое состояние не создаётся) |
{ "detail": "Missing signature headers (X-Signature-Timestamp, X-Signature)" } |
Нет одного из двух заголовков |
{ "detail": "Signature timestamp outside the allowed window" } |
Метка устарела, пришла из будущего или это не число |
{ "detail": "Invalid request signature" } |
Подпись не сошлась |
Схема 2. Исходящий callback
Подписывается строка
<id задачи>.<timestamp>
где <id задачи> — поле id из тела вызова (оно же в заголовке X-ASR-Task-Id), а
<timestamp> — значение X-Webhook-Timestamp.
Метка времени и подпись пересчитываются на каждую попытку доставки, а не один раз на задачу: иначе повтор через минуту принёс бы метку минутной давности и не прошёл бы окно свежести у приёмника. У четырёх попыток будут четыре разные пары заголовков, и проверять надо ту, что пришла с текущим запросом.
Окно свежести
| Параметр | По умолчанию | Описание |
|---|---|---|
SIGNATURE_MAX_SKEW_SEC |
300 |
Допустимый разбег часов для X-Signature-Timestamp на входящих подписанных запросах, секунды |
Запрос принимается, если |сейчас − timestamp| ≤ SIGNATURE_MAX_SKEW_SEC, то есть подпись живёт
5 минут в обе стороны — перехваченный запрос нельзя повторить назавтра. Та же метка кладётся в
исходящий callback, чтобы приёмник мог применить своё окно той же ширины. Если часы убегают,
лечится это синхронизацией времени (NTP), а не расширением окна.
Пример: подписанный запрос (bash + openssl)
BASE=http://localhost:8020
API_KEY=sk-XXXXXXXX...
SECRET=whsec_2Vd1kZ0oQ7pR... # секретное слово этого ключа
REQ_PATH="/transcribe/async"
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST.$REQ_PATH" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| base64)
curl -s -X POST "$BASE$REQ_PATH" \
-H "Authorization: Bearer $API_KEY" \
-H "X-Signature-Timestamp: $TS" \
-H "X-Signature: $SIG" \
-F "file=@./audio.mp3" \
-F "language=ru"
printf '%s' здесь важен: echo добавил бы перевод строки в подписываемые данные.
Пример: подпись запроса на Python
import base64
import hashlib
import hmac
import time
import requests
BASE = "http://localhost:8020"
API_KEY = "sk-XXXXXXXX..."
SECRET = "whsec_2Vd1kZ0oQ7pR..."
def signed_headers(method: str, path_with_query: str, secret: str) -> dict:
ts = str(int(time.time()))
data = f"{ts}.{method.upper()}.{path_with_query}"
digest = hmac.new(secret.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
return {
"X-Signature-Timestamp": ts,
"X-Signature": base64.b64encode(digest).decode("ascii"),
}
# URL собираем сами — так подписанный путь и отправленный совпадают символ в символ
path = "/transcribe/async"
headers = {"Authorization": f"Bearer {API_KEY}"}
headers.update(signed_headers("POST", path, SECRET))
resp = requests.post(
BASE + path,
headers=headers,
data={
"url": "https://example.com/records/call.mp3",
"callback_url": "https://example.com/hooks/asr",
"language": "ru",
},
timeout=30, # 202 приходит сразу: ссылку сервис скачивает уже после ответа
)
resp.raise_for_status()
print(resp.json()["id"])
Пример: проверка callback на Python (Flask)
import base64
import hashlib
import hmac
import time
from flask import Flask, jsonify, request
SECRET = "whsec_2Vd1kZ0oQ7pR..."
MAX_SKEW_SEC = 300
app = Flask(__name__)
@app.post("/hooks/asr")
def asr_callback():
ts = request.headers.get("X-Webhook-Timestamp", "")
received = request.headers.get("X-Webhook-Signature", "")
if not ts or not received:
return jsonify(error="Missing signature headers"), 401
try:
if abs(int(time.time()) - int(ts)) > MAX_SKEW_SEC:
return jsonify(error="Stale timestamp"), 401
except ValueError:
return jsonify(error="Bad timestamp"), 401
task = request.get_json(silent=True) or {}
data = f"{task.get('id', '')}.{ts}" # id задачи из тела, он же в X-ASR-Task-Id
expected = base64.b64encode(
hmac.new(SECRET.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
if not hmac.compare_digest(expected, received):
return jsonify(error="Invalid signature"), 401
# подпись верна — телу можно доверять
print(task["id"], task["status"], len(task.get("text") or ""))
return jsonify(status="received"), 200
Пример: проверка callback на PHP
<?php
header('Content-Type: application/json');
$secret = getenv('ASR_WEBHOOK_SECRET');
$maxSkew = 300;
$ts = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if ($ts === '' || $sig === '') {
http_response_code(401);
exit(json_encode(['error' => 'Missing signature headers']));
}
if (abs(time() - (int) $ts) > $maxSkew) {
http_response_code(401);
exit(json_encode(['error' => 'Stale timestamp']));
}
$task = json_decode(file_get_contents('php://input'), true); // тело — JSON, не форма
$taskId = $task['id'] ?? ''; // он же в X-ASR-Task-Id
$expected = base64_encode(hash_hmac('sha256', $taskId . '.' . $ts, $secret, true));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit(json_encode(['error' => 'Invalid signature']));
}
// подпись верна — обрабатываем $task['status'] и $task['text']
http_response_code(200);
echo json_encode(['status' => 'received']);
Переменные окружения
Все новые настройки задаются в .env и имеют рабочие значения по умолчанию — менять их для
включения подписи или callback не требуется.
| Переменная | По умолчанию | Описание |
|---|---|---|
FETCH_TIMEOUT_SEC |
120 |
Абсолютный дедлайн на скачивание аудио по url в POST /transcribe/ (внутри запроса) и предел молчания источника посреди передачи в обоих эндпоинтах, секунды |
FETCH_BACKGROUND_TIMEOUT_SEC |
3600 |
Абсолютный дедлайн фонового скачивания по url в POST /transcribe/async, секунды; отсчитывается с начала скачивания, а не с приёма задачи |
FETCH_MAX_CONCURRENT |
4 |
Сколько фоновых скачиваний POST /transcribe/async идёт одновременно; остальные ждут свободного места |
FETCH_MAX_REDIRECTS |
5 |
Сколько редиректов разрешено пройти при скачивании (каждый проверяется политикой хостов) |
FETCH_ALLOW_PRIVATE_HOSTS |
false |
Разрешить непубличные адреса (приватные, loopback, link-local, CGNAT 100.64.0.0/10) в url и callback_url. См. предупреждение в разделе 8: включённый, он превращает любой API-ключ в HTTP-прокси внутрь периметра |
CALLBACK_TIMEOUT_SEC |
30 |
Таймаут одной попытки доставки callback, секунды |
CALLBACK_RETRIES |
3 |
Сколько повторов после первой неудачной попытки (паузы 2 / 10 / 60 с) |
CALLBACK_POLL_SECONDS |
2.0 |
Как часто фоновый поток ищет готовые задачи с заказанным вызовом, секунды |
SIGNATURE_MAX_SKEW_SEC |
300 |
Окно свежести X-Signature-Timestamp у входящих подписанных запросов, секунды |
Обработка ошибок
Все ошибки возвращаются в стандартном формате:
{
"detail": "Описание ошибки"
}
Коды ошибок:
- 400: Неверный запрос (в том числе битый
url/callback_url, а вPOST /transcribe/— и неподдерживаемый формат по ссылке) - 401: Не авторизован (в том числе неверная или отсутствующая подпись — см. раздел 9)
- 403: Доступ запрещен (в том числе адрес, запрещённый политикой хостов)
- 404: Ресурс не найден
- 413: Файл слишком большой (больше
MAX_FILE_SIZE_MB, по умолчанию 5000 МБ); для ссылки — только вPOST /transcribe/ - 429: Превышен лимит запросов
- 500: Внутренняя ошибка сервера
- 502: Источник по
urlнедоступен или ответил5xx(толькоPOST /transcribe/) - 504: Источник по
urlне уложился вFETCH_TIMEOUT_SEC(толькоPOST /transcribe/)
Не всякая неудача — это HTTP-код: файл приняли, а сломалась уже обработка. Тогда задача переходит
в failed, а причина лежит в error_message (GET /transcriptions/{id}/status). Так, файл, в
котором вовсе нет звуковой дорожки, даёт: «В файле нет звуковой дорожки — распознавать нечего.»
Так же ведёт себя ссылка в POST /transcribe/async: ответ 202 приходит до скачивания, и любая
неудача скачивания, кроме отказа по самой ссылке (400/403), — это failed с error_message
«Не удалось скачать файл по ссылке: …» (см. раздел 8).
Пример обработки ошибок (Python):
import requests
def safe_api_call(url, headers, method="GET", **kwargs):
try:
if method == "GET":
response = requests.get(url, headers=headers, **kwargs)
elif method == "POST":
response = requests.post(url, headers=headers, **kwargs)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
print("Authentication required. Please login.")
elif response.status_code == 403:
print("Access denied. Check permissions.")
elif response.status_code == 404:
print("Resource not found.")
elif response.status_code == 429:
print("Rate limit exceeded. Please wait.")
else:
error = response.json()
print(f"Error: {error.get('detail', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"Network error: {e}")
return None
Полный пример использования
Python - Полный workflow:
import requests
import time
class Speech2TextClient:
def __init__(self, base_url="http://localhost:8020"):
self.base_url = base_url
self.token = None
def login(self, username, password):
"""Авторизация в системе"""
response = requests.post(
f"{self.base_url}/auth/login",
json={"username": username, "password": password}
)
if response.status_code == 200:
self.token = response.json()["access_token"]
print("Login successful!")
return True
return False
def transcribe(self, audio_path, language=None):
"""Транскрибация аудиофайла"""
if not self.token:
print("Please login first")
return None
headers = {"Authorization": f"Bearer {self.token}"}
with open(audio_path, "rb") as f:
files = {"file": f}
data = {"language": language} if language else {}
response = requests.post(
f"{self.base_url}/transcribe/",
headers=headers,
files=files,
data=data
)
if response.status_code == 200:
return response.json()
else:
print(f"Error: {response.text}")
return None
def wait_for_completion(self, transcription_id, timeout=300):
"""Ожидание завершения транскрибации"""
headers = {"Authorization": f"Bearer {self.token}"}
start_time = time.time()
while time.time() - start_time < timeout:
response = requests.get(
f"{self.base_url}/transcriptions/{transcription_id}/status",
headers=headers
)
if response.status_code == 200:
status = response.json()
if status["status"] == "completed":
return True
elif status["status"] == "failed":
print(f"Transcription failed: {status.get('error_message')}")
return False
time.sleep(2)
print("Timeout waiting for transcription")
return False
def get_transcription(self, transcription_id):
"""Получение результата транскрибации"""
headers = {"Authorization": f"Bearer {self.token}"}
response = requests.get(
f"{self.base_url}/transcribe/{transcription_id}",
headers=headers
)
if response.status_code == 200:
return response.json()
return None
def download_text(self, transcription_id, output_path):
"""Скачивание текста транскрибации"""
headers = {"Authorization": f"Bearer {self.token}"}
response = requests.get(
f"{self.base_url}/transcriptions/{transcription_id}/download",
headers=headers
)
if response.status_code == 200:
with open(output_path, "w", encoding="utf-8") as f:
f.write(response.text)
print(f"Transcription saved to {output_path}")
return True
return False
# Использование
if __name__ == "__main__":
client = Speech2TextClient()
# Авторизация
if client.login("admin", "admin123"):
# Транскрибация
result = client.transcribe("podcast.mp3", language="ru")
if result:
print(f"Transcription ID: {result['id']}")
print(f"Status: {result['status']}")
# Ожидание завершения
if result['status'] == 'processing':
if client.wait_for_completion(result['id']):
# Получение результата
full_result = client.get_transcription(result['id'])
print(f"Text: {full_result['text'][:200]}...")
# Сохранение в файл
client.download_text(result['id'], "transcription.txt")
JavaScript - Полный workflow:
class Speech2TextClient {
constructor(baseUrl = 'http://localhost:8020') {
this.baseUrl = baseUrl;
this.token = null;
}
async login(username, password) {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (response.ok) {
const data = await response.json();
this.token = data.access_token;
console.log('Login successful!');
return true;
}
return false;
}
async transcribe(file, language = null) {
if (!this.token) {
console.error('Please login first');
return null;
}
const formData = new FormData();
formData.append('file', file);
if (language) {
formData.append('language', language);
}
const response = await fetch(`${this.baseUrl}/transcribe/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
},
body: formData
});
if (response.ok) {
return await response.json();
}
return null;
}
async waitForCompletion(transcriptionId, timeout = 300000) {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const response = await fetch(
`${this.baseUrl}/transcriptions/${transcriptionId}/status`,
{
headers: {'Authorization': `Bearer ${this.token}`}
}
);
if (response.ok) {
const status = await response.json();
if (status.status === 'completed') {
return true;
} else if (status.status === 'failed') {
console.error('Transcription failed:', status.error_message);
return false;
}
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
console.error('Timeout waiting for transcription');
return false;
}
async getTranscription(transcriptionId) {
const response = await fetch(
`${this.baseUrl}/transcribe/${transcriptionId}`,
{
headers: {'Authorization': `Bearer ${this.token}`}
}
);
if (response.ok) {
return await response.json();
}
return null;
}
}
// Использование
async function main() {
const client = new Speech2TextClient();
// Авторизация
if (await client.login('admin', 'admin123')) {
// Выбор файла
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
// Транскрибация
const result = await client.transcribe(file, 'ru');
if (result) {
console.log('Transcription ID:', result.id);
// Ожидание завершения
if (result.status === 'processing') {
if (await client.waitForCompletion(result.id)) {
// Получение результата
const fullResult = await client.getTranscription(result.id);
console.log('Text:', fullResult.text);
// Отображение результата
document.getElementById('result').textContent = fullResult.text;
}
}
}
});
}
}
main();
Советы и рекомендации
Оптимизация производительности:
- Формат файлов: Конвертируйте в WAV 16kHz моно для быстрой обработки
- Размер файлов: Разбивайте большие файлы на части по 30 минут
- Язык: Указывайте язык явно для ускорения обработки
- Batch обработка: Загружайте несколько файлов параллельно
- Видео:
.mov,.mxf,.webm,.mp4можно слать как есть — звук извлечёт сервис. Но по сети едет весь контейнер (реальный MXF — 3.88 ГБ ради процентов полезных данных), так что на узком канале дешевле вырезать звук у себя и прислать, например,.m4a
Безопасность:
- Храните токены безопасно: Никогда не включайте токены в код
- Используйте HTTPS: В продакшене всегда используйте HTTPS
- Ротация ключей: Регулярно обновляйте API ключи
- Ограничение прав: Давайте минимально необходимые права
- Подпись запросов: Включите
require_request_signature— украденный ключ без секретного слова станет бесполезен (раздел 9) - Проверяйте подпись callback: Включите
sign_callbacksи проверяйтеX-Webhook-Signatureна приёмнике — иначе кто угодно может прислать вам «результат»
Обработка ошибок:
- Retry логика: Реализуйте повторные попытки при временных ошибках
- Таймауты: Устанавливайте разумные таймауты для долгих операций
- Логирование: Логируйте все ошибки для отладки
- Graceful degradation: Предусмотрите fallback сценарии
Полезные ссылки
- Swagger UI: http://localhost:8020/docs
- ReDoc: http://localhost:8020/redoc
- Admin Panel: http://localhost:8020/admin/
- GitHub: https://github.com/yourusername/speech2text
- Поддержка: support@speech2text.com
Поддержка
При возникновении проблем: 1. Проверьте логи сервера 2. Убедитесь в правильности токена 3. Проверьте формат и размер файла 4. Обратитесь к документации API
Для технической поддержки создайте issue на GitHub или напишите на support@speech2text.com