Compare commits

...
35 Commits
Author SHA1 Message Date
Vladimir BryzgalovandClaude Opus 5 f56ed7b5a0 Версия 0.14.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 19:00:50 +05:00
Vladimir BryzgalovandClaude Opus 5 cd02fabb2e Caddyfile без предупреждений при запуске
Caddy ругался на два места. header_up X-Forwarded-For был лишним: Caddy сам
игнорирует этот заголовок из входящего запроса и подставляет реальный адрес,
защита от подделки обеспечивается его поведением по умолчанию. Пустая строка
перед глобальным блоком делала файл неформатированным по мнению caddy fmt.

Проверено caddy fmt и caddy validate на сгенерированном файле.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 19:00:49 +05:00
Vladimir BryzgalovandClaude Opus 5 7494883309 Версия 0.14.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:38:35 +05:00
Vladimir BryzgalovandClaude Opus 5 d4a24af6f0 Готовые предложения в ответе для разметки ролей
Разбивка на предложения нужна LLM-разметке и была бы дублирована на стороне
Talkscore. Логика живёт рядом с ASR, который и ставит пунктуацию, а сам
вызов LLM остаётся на Talkscore: промпт меняется чаще, чем расшифровка,
и переразметить сохранённый текст дешевле, чем гонять аудио заново.

Документ интеграции дополнен итогами сравнения моделей, batch-ценами
и проверками формата ответа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:38:34 +05:00
Vladimir BryzgalovandClaude Opus 5 1e348fb47c Версия 0.13.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:07:31 +05:00
Vladimir BryzgalovandClaude Opus 5 e7deb47196 Документация выключена по умолчанию и закрыта токеном
Маршруты /docs и /openapi.json проверяли только адрес. С выключенным
списком адресов это означало открытый доступ к описанию API из интернета.
Теперь они отвечают 404, пока docs не включён явно, а включённые требуют
адрес из списка и токен - заголовком либо ссылкой /docs?token=ЗНАЧЕНИЕ.

Попутно исправлено сравнение токена: compare_digest на строках с не-ASCII
бросает TypeError, и токен с кириллицей давал 500 вместо 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:07:30 +05:00
Vladimir BryzgalovandClaude Opus 5 2039d95a86 Версия 0.12.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:23:48 +05:00
Vladimir BryzgalovandClaude Opus 5 febec7f124 Логика запуска и Caddyfile переехали внутрь app
Файлы вне папки app обновление не доставляло: чтобы изменить порядок
запуска или конфигурацию Caddy, требовалась переустановка. Теперь .bat -
заглушка из трёх строк, вызывающая app/launcher.py, а Caddyfile создаётся
из настроек [https] в config.toml. Отредактированный вручную Caddyfile
не затирается: новая версия откладывается рядом как Caddyfile.new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:23:47 +05:00
Vladimir BryzgalovandClaude Opus 5 cd3acafa8a Версия 0.11.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:05:38 +05:00
Vladimir BryzgalovandClaude Opus 5 6812220d8f Единый формат логов и понятное сообщение о занятом порте
Uvicorn заводил свои обработчики с propagate=False, поэтому его строки шли
мимо нашего формата и рвались посреди слова. log_config=None отдаёт всё
общему обработчику.

Занятый порт сервис теперь замечает до запуска и объясняет, что делать,
вместо строки uvicorn про Errno 10048.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:05:38 +05:00
Vladimir BryzgalovandClaude Opus 5 d4c8f72e6d Версия 0.11.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:56:58 +05:00
Vladimir BryzgalovandClaude Opus 5 ec0e84935e Читаемый вывод в консоли: цвета и перенос строк
Windows-консоль не разбирала ANSI и печатала их как мусор, поэтому цвет
был выключен целиком. Теперь разбор включается через WinAPI, а при неудаче
цвет молча отключается. Длинные сообщения переносятся по словам с отступом
под текст. Вывод Caddy уведён в файл - он забивал консоль сертификатами.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:56:58 +05:00
Vladimir BryzgalovandClaude Opus 5 8ca3d73300 Версия 0.10.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:54:02 +05:00
Vladimir BryzgalovandClaude Opus 5 4f6897855a Понятное сообщение при сломанном config.toml
Дублированный раздел ронял обновление трассировкой tomllib. Теперь
ConfigError объясняет по-русски, что раздел объявлен дважды и какие
разделы должны встречаться ровно один раз.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:53:56 +05:00
Vladimir BryzgalovandClaude Opus 5 2f2df7d390 Исправлены цифры стоимости разметки ролей
Прежняя оценка \$0,035 за час была расчётной и ошибочной в двадцать раз:
я считал выход по формату ответа, а обе модели оказались рассуждающими
и тратят на размышления тысячи токенов.

Замер: полные размышления \$0,75 за час, минимальные \$0,076, выключенные
\$0,055. Но при выключенных модель начинает менять роли местами в середине
разговора, поэтому рекомендованы минимальные.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:48:13 +05:00
Vladimir BryzgalovandClaude Opus 5 c992b7907d HTTPS через Caddy с автоматическими сертификатами
Токен и расшифровки разговоров ходили по сети открытым текстом. Теперь в
пакете лежит Caddy: он держит сертификаты Let's Encrypt и продлевает их сам,
а сервис уходит на localhost.

Отдельно решён вопрос подмены адреса: за прокси все запросы приходят с
localhost, и список разрешённых адресов пускал бы кого угодно. Заголовку
с настоящим адресом сервис верит только от доверенного прокси - на это
восемь тестов.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:41:34 +05:00
Vladimir BryzgalovandClaude Opus 5 64ce562bb1 Замеры на восьми реальных записях
205 минут разговоров: время по этапам, стоимость разметки ролей и качество
разделения говорящих. Главная цифра - разделение по голосам сработало на
одной записи из восьми, поэтому роли восстанавливает LLM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:27:37 +05:00
Vladimir BryzgalovandClaude Opus 5 a1f420d61c Словарь автошколы и документ по интеграции с Talkscore
Словарь замен расширен под нишу: категории прав, документы, госорганы,
термины обучения и оплаты, частые ошибки на плохом звуке.

В docs - инструкция для агента Talkscore: что выключить на своей стороне,
как ставить задачи и принимать вебхук, и главное - почему полю speaker
доверять нельзя и как восстанавливать роли через LLM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 01:15:11 +05:00
Vladimir BryzgalovandClaude Opus 5 eda6fa143a Выравнивание громкости перед обработкой
Ключевая находка по записям с микрофоном на столе: без выравнивания тихий
дальний участник сливается с громким и разделение разваливается. Замер на
трёх разговорах: доля второго участника выросла с 1.8 до 24.3 процента,
переключений между репликами - с 21 до 71 процента.

Работает только выравнивание: шумоподавление, полосовой фильтр и компандер
не дали ничего. Параметры подобраны замером, f=400:g=3 давал 8.6 процента
вместо 43.2. На распознавание не влияет - расшифровки фрагментов совпали
дословно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:40:52 +05:00
Vladimir BryzgalovandClaude Opus 5 e6e10dddd1 Вебхук: сервис сам сообщает о готовой задаче
Опрос статуса заставлял принимающую сторону дёргать сервис каждые
несколько секунд. Теперь при завершении задачи результат уходит POST-ом
на заданный адрес, с подписью HMAC-SHA256 в заголовке и тремя попытками
при неудаче. Адрес задаётся в настройках или параметром запроса.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:24:25 +05:00
Vladimir BryzgalovandClaude Opus 5 710fc8b90b Оценка разделения больше не роняет задачу, журнал доступен через API
Полный трейс показал настоящую причину сбоя: падало не распознавание,
а метрика separation_quality из 0.5.0 - она скармливала модели отпечатков
реплики целиком, и на 190-секундной та не выдержала. Теперь берётся кусок
из середины реплики, а сбой оценки лишь обнуляет её, не трогая расшифровку.

Добавлен /v1/logs: последние записи журнала с фильтром по уровню, чтобы
разбирать сбои без копирования консоли вручную.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:44:09 +05:00
Vladimir BryzgalovandClaude Opus 5 f2f266ba1c Устойчивое распознавание длинных реплик
На Windows реплика в 190 секунд роняет энкодер GigaAM в свёртке, хотя на
macOS та же самая проходит: пределы у сборок onnxruntime разные и заранее
неизвестны. Вместо подбора константы кусок при ошибке делится пополам и
пробуется снова, до пяти уровней. Базовый размер куска снижен со 150 до 60
секунд - с запасом под любую сборку.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:37:17 +05:00
Vladimir BryzgalovandClaude Opus 5 91b69ebe06 Очередь открывается при старте сервиса, а не при импорте модуля
На Windows процессы поднимаются через spawn и заново импортируют app.main.
Хранилище задач создавалось на уровне модуля, поэтому каждый новый процесс
при старте помечал чужие выполняющиеся задачи как сорванные - все три
записи падали с "сервис был перезапущен во время обработки".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:29:38 +05:00
Vladimir BryzgalovandClaude Opus 5 41f3651cc8 Параллельные задачи считаются в отдельных процессах
Замер подтвердил догадку: sherpa-onnx и onnxruntime держат GIL. Две задачи
в двух потоках идут ровно столько же, сколько подряд (1.04x у диаризации,
1.17x у распознавания) - отсюда и незагруженный процессор при работе.
В двух процессах те же задачи дают 1.59x даже с загрузкой моделей в каждом.

Пул процессов включается при workers > 1. Функции воркера вынесены в модуль,
не тянущий app.main: на Windows дочерний процесс поднимается через spawn и
иначе стартовал бы ещё один веб-сервер. Если процессы не запустятся, сервис
откатывается на однопроцессный режим.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:27:07 +05:00
Vladimir BryzgalovandClaude Opus 5 e2db164f0b Признаки для расстановки ролей и оценка надёжности разделения
На записях с микрофоном на столе голоса участников для модели почти
неразличимы: перебор четырёх моделей отпечатков и смена алгоритма
кластеризации баланс улучшают, но роли всё равно скачут.

Поэтому сервис теперь отдаёт то, на что можно опереться: акустику каждой
реплики (громкость, доля высоких, центроид - они связаны с расстоянием
до микрофона) и метрику separation_quality с флагом speakers_reliable.
Ниже 0.35 разметка по говорящим случайна, и роли должна определять LLM
по смыслу реплик.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:21:56 +05:00
Vladimir BryzgalovandClaude Opus 5 4de6b14680 Дефолт числа потоков зависит от машины, а не от одного замера
Бенчмарк на Ryzen 9 9950X показал обратное тому, что было на Apple M4:
там всё растёт до 16 потоков (распознавание x14.7 -> x61.3), здесь после
четырёх начинается спад. Причина в неоднородных ядрах M4.

Дефолт стал умеренным (половина ядер, но не больше 8), а точное значение
подбирается через /v1/benchmark. Воркер по умолчанию один: параллельная
обработка выигрыша не дала, процессор и так загружен целиком.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:09:58 +05:00
Vladimir BryzgalovandClaude Opus 5 42a98bd703 Эндпоинт подбора числа потоков
Масштабирование ONNX зависит от процессора: замеры на Apple M4 не
переносятся на Ryzen, а подбирать конфигурацию перезапусками мучительно.
Теперь /v1/benchmark гоняет минуту записи на 1, 2, 4, 8 и 16 потоках
и говорит, что поставить в config.toml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:01:55 +05:00
Vladimir BryzgalovandClaude Opus 5 c1ba61fcd5 Воркеры грузят модели при первой задаче
Прогрет был только нулевой воркер, остальные получали пустой конвейер и
роняли задачу с "модели не загружены". В комментарии было написано, что
они греются сами, но кода для этого не было.

Заодно отключены ANSI-цвета uvicorn на Windows: консоль их не разбирает
и печатала управляющие последовательности как мусор.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:51:54 +05:00
Vladimir BryzgalovandClaude Opus 5 be837bab0b Параллельная обработка вместо широких потоков
Замеры: распознавание даёт x52 на 4 потоках против x13 на 16, разделение
говорящих x33 против x8. Дальше четырёх потоков синхронизация съедает весь
выигрыш, поэтому ядра занимаются несколькими задачами сразу.

По умолчанию 4 потока на задачу и до 4 задач параллельно. Захват задачи
из очереди стал атомарным - без этого два воркера брали одну и ту же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:33:54 +05:00
Vladimir BryzgalovandClaude Opus 5 e3b4e3c73f Страница с описанием методов для браузера
Штатные /docs и /openapi.json не требуют токена, поэтому раньше были
выключены совсем. Теперь это свои маршруты, закрытые тем же списком
адресов, что и остальной сервис: со своей машины открываются, с чужой
отдают 403. У методов появились описания, в схеме объявлен Bearer -
работает кнопка Authorize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:23:16 +05:00
Vladimir BryzgalovandClaude Opus 5 13cccb18d1 Понятно, где лежат настройки
Правки в app/config.py не действуют и теряются при обновлении: это код,
а настройки читаются из config.toml. Теперь при старте печатается путь
к нему и что фактически применилось - задан ли токен и сколько адресов
разрешено. В самом модуле - предупреждение вверху файла.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:08:28 +05:00
Vladimir BryzgalovandClaude Opus 5 c704fb5068 health показывает версию и вердикт по адресу клиента
Отказ по списку доступа отлаживался вслепую: сервис не сообщал, каким видит
адрес обратившегося. Теперь /health отдаёт your_ip, your_ip_allowed и версию,
не раскрывая при этом сам список.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:05:01 +05:00
Vladimir BryzgalovandClaude Opus 5 59857fff0f Исправлено разрушение папки app при обновлении на Windows
Обновление перемещало папку app целиком, а код выполняется именно из неё.
Windows не даёт двигать такую папку: перемещение не прошло, откат тоже,
и app исчезла - сервис переставал запускаться.

Теперь папка остаётся на месте: новая версия проверяется во временном
каталоге ДО установки, затем файлы перезаписываются по одному с копией
для отката. Устаревшие модули удаляются, __pycache__ не трогается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:00:03 +05:00
Vladimir BryzgalovandClaude Opus 5 b8cbaa6b82 Понятная диагностика при отказе в доступе к релизам
Gitea на приватном репозитории отвечает 404 вместо 403, и сообщение
"404 Not Found" не подсказывало, что дело в токене. Теперь при 401/403/404
объясняется причина и куда вписать токен.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:54:48 +05:00
Vladimir BryzgalovandClaude Opus 5 44becfbe5e Автообновление кода через релизы Gitea
Сервис при запуске проверяет последний релиз и обновляет только папку app -
19 КБ вместо 113 МБ всего пакета. Контрольная сумма, откат при неудачной
проверке, пользовательские файлы не трогаются.

Попутно: ffmpeg ищется лениво, а не в конструкторе Pipeline - его отсутствие
роняло импорт вместо внятного сообщения. Схема API закрыта по умолчанию.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:44:52 +05:00
41 changed files with 3083 additions and 158 deletions
+40
View File
@@ -153,6 +153,46 @@ for turn in r["turns"]:
Заодно постобработка приводит типографику к принятому виду: все виды тире
заменяются на дефис, кавычки отбиваются пробелом.
## Обновления
Сервис проверяет новую версию при каждом запуске и обновляет только папку `app` -
это десятки килобайт. Python, библиотеки, ffmpeg и модели остаются на месте,
перекидывать весь пакет заново не нужно.
Настройка в `config.toml`:
```toml
[update]
enabled = true
server = "https://git.netranking.ru"
repo = "bryzgalov/talkscore-asr"
token = "" # токен Gitea с правом чтения; для публичного репозитория не нужен
```
Репозиторий приватный, поэтому токен обязателен. Создать его: Gitea → Settings →
Applications → Generate Token, достаточно права `read:repository`.
Как это работает:
1. `start.bat` перед запуском сервиса спрашивает у Gitea последний релиз
2. если версия там новее, скачивает `app-<версия>.zip` и сверяет контрольную сумму
3. откладывает текущий код, ставит новый и проверяет, что он импортируется
4. если проверка не прошла, возвращает предыдущую версию
Нет сети или Gitea недоступен - сервис просто запускается на текущей версии.
`config.toml` и `replacements.txt` обновление не трогает: они ваши.
Чтобы выключить проверку совсем, поставьте `enabled = false`.
### Выпуск новой версии
```bash
uv run --with requests python build/release.py 0.2.0 -m "что изменилось"
```
Скрипт проставит версию в `app/version.py`, соберёт архив только из кода
и опубликует релиз с контрольной суммой. Целевая машина подхватит его
при следующем запуске.
## Автозапуск
Чтобы сервис поднимался при старте Windows, создайте задачу в планировщике:
+55
View File
@@ -0,0 +1,55 @@
"""Акустические признаки реплики.
На записи с одним микрофоном на столе участники сидят на разном расстоянии,
и это слышно: дальний голос глуше и тише. Тембровые модели такую разницу
почти не улавливают, поэтому признаки считаются отдельно и отдаются наружу -
по ним LLM может уточнить, кто где, когда разделение по голосу ненадёжно.
"""
import numpy as np
__all__ = ["segment_acoustics", "separation_quality"]
SAMPLE_RATE = 16000
def segment_acoustics(samples: np.ndarray) -> dict:
"""Считает признаки, связанные с расстоянием до микрофона."""
if len(samples) < SAMPLE_RATE // 4:
return {}
rms = float(np.sqrt((samples ** 2).mean()) + 1e-9)
spectrum = np.abs(np.fft.rfft(samples * np.hanning(len(samples))))
freqs = np.fft.rfftfreq(len(samples), 1 / SAMPLE_RATE)
total = float(spectrum.sum()) + 1e-9
return {
# Громкость: ближний к микрофону обычно громче
"loudness_db": round(20 * float(np.log10(rms)), 1),
# Доля высоких частот: у дальнего голоса верх съедается расстоянием
"hf_ratio": round(float(spectrum[(freqs > 3000) & (freqs < 7000)].sum()) / total, 3),
# Центр тяжести спектра, тот же смысл другим числом
"centroid_hz": round(float((spectrum * freqs).sum()) / total),
}
def separation_quality(embeddings: np.ndarray, labels: np.ndarray) -> float:
"""Насколько уверенно голоса разошлись по кластерам.
Единица - участники различаются чётко, около нуля - модель фактически
не видит разницы и разметка по говорящим случайна. На записях с одним
микрофоном в комнате значение обычно низкое.
"""
if len(embeddings) < 4 or len(set(labels.tolist())) < 2:
return 0.0
norm = embeddings / (np.linalg.norm(embeddings, axis=1, keepdims=True) + 1e-9)
distances = 1 - norm @ norm.T
scores = []
for i in range(len(norm)):
own = distances[i][(labels == labels[i])]
other = distances[i][(labels != labels[i])]
own = own[own > 0]
if len(own) and len(other):
a, b = float(own.mean()), float(other.mean())
scores.append((b - a) / max(a, b))
return round(float(np.mean(scores)), 3) if scores else 0.0
+63
View File
@@ -0,0 +1,63 @@
"""Подбор оптимального числа потоков на конкретной машине.
Масштабирование ONNX сильно зависит от процессора: на Apple M4 оптимум
оказался в районе 4 потоков, на Ryzen картина другая. Гадать бесполезно,
поэтому сервис умеет померить сам.
"""
import time
from pathlib import Path
import numpy as np
from app.pipeline import SAMPLE_RATE, Pipeline
__all__ = ["run_benchmark"]
# Больше 16 потоков смысла мерить нет: везде, где замеряли, деградация
# начиналась гораздо раньше.
THREAD_OPTIONS = (1, 2, 4, 8, 16)
def run_benchmark(samples: np.ndarray, models_dir: Path, base_dir: Path,
replacements_path: Path, seconds: float = 60.0,
speakers: int = 2) -> dict:
"""Гоняет один и тот же фрагмент на разном числе потоков."""
clip = samples[: int(seconds * SAMPLE_RATE)]
duration = len(clip) / SAMPLE_RATE
rows = []
for threads in THREAD_OPTIONS:
pipe = Pipeline(models_dir=models_dir, threads=threads,
replacements_path=replacements_path, base_dir=base_dir)
pipe.warmup()
t0 = time.time()
pipe._diarizer(speakers).process(clip)
diar = time.time() - t0
t0 = time.time()
pipe._asr.recognize(clip[: int(30 * SAMPLE_RATE)], sample_rate=SAMPLE_RATE)
asr = time.time() - t0
rows.append({
"threads": threads,
"diarization_sec": round(diar, 2),
"diarization_x": round(duration / diar, 1) if diar else 0,
"asr_sec": round(asr, 2),
"asr_x": round(min(30.0, duration) / asr, 1) if asr else 0,
})
best_diar = max(rows, key=lambda r: r["diarization_x"])
best_asr = max(rows, key=lambda r: r["asr_x"])
return {
"clip_sec": round(duration, 1),
"results": rows,
"best_for_diarization": best_diar["threads"],
"best_for_asr": best_asr["threads"],
"recommended_threads": best_diar["threads"],
"hint": (
f"Поставьте threads = {best_diar['threads']} в config.toml. "
"Разделение говорящих занимает большую часть времени, поэтому "
"ориентируемся на него."
),
}
+87 -9
View File
@@ -1,11 +1,20 @@
"""Настройки сервиса: читаются из config.toml рядом с исполняемым файлом."""
"""Чтение настроек сервиса.
ЭТО КОД, А НЕ НАСТРОЙКИ. Править нужно файл config.toml рядом со start.bat -
он создаётся при первом запуске и обновлением не затрагивается. Правки в этом
файле не подействуют (config.toml уже существует) и пропадут при обновлении.
"""
import os
import secrets
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
__all__ = ["Settings", "load_settings", "BASE_DIR"]
__all__ = ["Settings", "load_settings", "BASE_DIR", "ConfigError"]
class ConfigError(RuntimeError):
"""Настройки прочитать не удалось. Текст рассчитан на чтение человеком."""
# Корень пакета: при запуске из ZIP это папка, куда его распаковали.
BASE_DIR = Path(os.environ.get("TALKSCORE_ASR_HOME") or Path(__file__).resolve().parent.parent)
@@ -24,21 +33,51 @@ token = "{token}"
# Кто может обращаться. Пусто = разрешены все адреса.
# Примеры: "192.168.1.0/24, 10.8.0.5, 127.0.0.1"
allow_ips = "127.0.0.1, ::1"
# Открывать ли /docs и /openapi.json. Они не требуют токена и показывают
# устройство API всем, кто знает адрес, поэтому по умолчанию выключены.
# Адреса обратных прокси, которым можно верить в определении клиента.
# Пусто = сервис работает напрямую и заголовкам не доверяет.
# При работе через start-https.bat (Caddy на этой же машине) ставьте 127.0.0.1
trust_proxy = ""
# Открывать ли /docs и /openapi.json. Они показывают устройство API, поэтому
# по умолчанию выключены и отвечают 404. Включённые - требуют адрес из списка
# и токен: в браузере его передают ссылкой вида /docs?token=ЗНАЧЕНИЕ.
docs = false
[processing]
# Сколько потоков отдать распознаванию. 0 = половина ядер.
# Потоков на одну задачу. 0 = половина ядер, но не больше 8.
# Оптимум зависит от процессора: подберите его через POST /v1/benchmark
# и впишите сюда полученное число.
threads = 0
# Сколько записей обрабатывать одновременно. Значение больше 1 включает
# отдельные процессы: библиотеки держат GIL, и в потоках задачи выстраиваются
# в очередь вместо параллельной работы. Каждый процесс держит свою копию
# моделей, это около 1 ГБ памяти на процесс.
workers = 1
# Ожидаемое число говорящих в записи. 0 = определять автоматически
# (на реальных звонках работает плохо, для диалога ставьте 2).
speakers = 2
# Выравнивать громкость перед обработкой. Для записей с микрофоном на столе
# это решающая настройка: без неё тихий участник сливается с громким и
# разделение по говорящим разваливается. На распознавание не влияет.
normalize = true
# Максимальный размер загружаемого файла, МБ
max_upload_mb = 500
# Сколько часов хранить результаты завершённых задач
keep_results_hours = 72
[webhook]
# Куда сообщать о готовых задачах. Пусто = не сообщать, забирайте опросом.
# Можно переопределить для отдельной задачи параметром webhook в запросе.
url = ""
# Секрет для подписи: сервис положит её в заголовок X-Talkscore-Signature,
# чтобы принимающая сторона убедилась, что запрос от вас.
secret = ""
[https]
# Домены для start-https.bat через запятую. Caddy получит на них сертификаты
# Let's Encrypt сам. Требуются открытые снаружи порты 80 и 443 и A-записи,
# указывающие на эту машину. Caddyfile создаётся из этих настроек.
domains = ""
[update]
# Проверять обновления кода при каждом запуске. Обновляется только папка app,
# это десятки килобайт: Python, библиотеки и модели остаются на месте.
@@ -57,14 +96,20 @@ class Settings:
token: str = ""
allow_ips: str = ""
docs: bool = False
trust_proxy: str = ""
threads: int = 0
workers: int = 0
speakers: int = 2
normalize: bool = True
max_upload_mb: int = 500
keep_results_hours: float = 72.0
webhook_url: str = ""
webhook_secret: str = ""
update_enabled: bool = False
update_server: str = "https://git.netranking.ru"
update_repo: str = "bryzgalov/talkscore-asr"
update_token: str = ""
https_domains: str = ""
base_dir: Path = field(default_factory=lambda: BASE_DIR)
@property
@@ -83,12 +128,25 @@ class Settings:
def max_upload_bytes(self) -> int:
return self.max_upload_mb * 1024 * 1024
# Масштабирование сильно зависит от процессора, и универсального числа нет.
# На Ryzen 9 9950X всё растёт до 16 потоков (распознавание x14.7 -> x61.3),
# на Apple M4 после 4 потоков начинается спад: там половина ядер медленные
# и работа, попавшая на них, тормозит остальные.
# Поэтому дефолт умеренный, а точное значение подбирается через /v1/benchmark.
MAX_DEFAULT_THREADS = 8
MAX_WORKERS = 4
def effective_threads(self) -> int:
if self.threads > 0:
return self.threads
# Половина логических ядер: оставляем запас, чтобы машина не вставала колом
# во время обработки. На Ryzen 9 9950X это 16 потоков.
return max(1, (os.cpu_count() or 4) // 2)
cores = os.cpu_count() or 4
return max(1, min(self.MAX_DEFAULT_THREADS, cores // 2))
def effective_workers(self) -> int:
if self.workers > 0:
return self.workers
cores = os.cpu_count() or 4
return max(1, min(self.MAX_WORKERS, cores // self.effective_threads()))
def load_settings(config_path: Path | None = None) -> Settings:
@@ -98,11 +156,25 @@ def load_settings(config_path: Path | None = None) -> Settings:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_DEFAULT_CONFIG.format(token=secrets.token_urlsafe(32)), encoding="utf-8")
data = tomllib.loads(path.read_text(encoding="utf-8"))
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as exc:
# Самая частая причина - раздел объявлен дважды: настройки дописывали
# блоками, и рядом со старым [security] появился второй.
raise ConfigError(
f"не удалось прочитать {path}\n"
f" {exc}\n\n"
" Скорее всего, один и тот же раздел объявлен дважды.\n"
" Каждый раздел ([server], [security], [processing], [webhook],\n"
" [update], [https]) должен встречаться в файле ровно один раз,\n"
" а все его настройки - идти внутри него одним блоком."
) from exc
server = data.get("server", {})
security = data.get("security", {})
proc = data.get("processing", {})
upd = data.get("update", {})
hook = data.get("webhook", {})
https = data.get("https", {})
return Settings(
host=server.get("host", "0.0.0.0"),
@@ -110,10 +182,16 @@ def load_settings(config_path: Path | None = None) -> Settings:
token=str(security.get("token", "")),
allow_ips=str(security.get("allow_ips", "")),
docs=bool(security.get("docs", False)),
trust_proxy=str(security.get("trust_proxy", "")),
threads=int(proc.get("threads", 0)),
workers=int(proc.get("workers", 1)),
speakers=int(proc.get("speakers", 2)),
normalize=bool(proc.get("normalize", True)),
max_upload_mb=int(proc.get("max_upload_mb", 500)),
keep_results_hours=float(proc.get("keep_results_hours", 72)),
webhook_url=str(hook.get("url", "")),
webhook_secret=str(hook.get("secret", "")),
https_domains=str(https.get("domains", "")),
update_enabled=bool(upd.get("enabled", False)),
update_server=str(upd.get("server", "https://git.netranking.ru")),
update_repo=str(upd.get("repo", "bryzgalov/talkscore-asr")),
+107
View File
@@ -0,0 +1,107 @@
"""Читаемый вывод в консоли: цвета и перенос длинных строк.
Windows-консоль по умолчанию не разбирает ANSI-последовательности и печатает
их как мусор вида `[34mINFO[0m`. Начиная с Windows 10 разбор можно включить
через WinAPI - это и делается ниже. Если включить не удалось (старая система,
вывод перенаправлен в файл), цвета молча отключаются: лучше блёклый лог,
чем лог с мусором.
"""
import logging
import os
import shutil
import sys
import textwrap
from collections.abc import Callable
__all__ = ["enable_ansi", "ConsoleFormatter", "setup_logging"]
# Цвет только там, где он несёт смысл: обычные сообщения остаются обычными,
# чтобы предупреждения и ошибки было видно с одного взгляда.
COLORS = {
"DEBUG": "\033[90m",
"INFO": "",
"WARNING": "\033[33m",
"ERROR": "\033[31m",
"CRITICAL": "\033[1;31m",
}
DIM = "\033[90m"
RESET = "\033[0m"
MIN_WIDTH = 60
FALLBACK_WIDTH = 100
def enable_ansi() -> bool:
"""Включает разбор ANSI в консоли Windows. Возвращает, можно ли красить."""
if not sys.stdout.isatty():
return False
if os.name != "nt":
return True
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_uint32()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
return False
# ENABLE_VIRTUAL_TERMINAL_PROCESSING
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
except (ImportError, AttributeError, OSError):
return False
def console_width() -> int:
"""Ширина окна консоли. При перенаправлении в файл берём разумную по умолчанию."""
width = shutil.get_terminal_size((FALLBACK_WIDTH, 24)).columns
return max(MIN_WIDTH, width)
class ConsoleFormatter(logging.Formatter):
"""Формат вида `09:29:46 WARNING текст`, длинные строки переносятся по словам.
Продолжение переносится с отступом под текст сообщения, а не под начало
строки: так видно, где кончается одно сообщение и начинается следующее.
"""
def __init__(self, colored: bool,
width: Callable[[], int] = console_width) -> None:
super().__init__(datefmt="%H:%M:%S")
self.colored = colored
# Ширину берём через переданную функцию, а не из глобальной: окно консоли
# меняется на ходу, а в тестах ширину надо задавать явно.
self.width = width
def format(self, record: logging.LogRecord) -> str:
stamp = self.formatTime(record, self.datefmt)
level = record.levelname
head = f"{stamp} {level:<8}"
indent = " " * len(head)
body = record.getMessage()
if record.exc_info:
body = f"{body}\n{self.formatException(record.exc_info)}"
lines: list[str] = []
room = self.width() - len(head)
for chunk in body.splitlines() or [""]:
lines.extend(textwrap.wrap(chunk, width=room) or [""])
if not self.colored:
return "\n".join([head + lines[0]]
+ [indent + line for line in lines[1:]])
tint = COLORS.get(level, "")
painted = f"{DIM}{stamp}{RESET} {tint}{level:<8}{RESET}" if tint \
else f"{DIM}{stamp}{RESET} {level:<8}"
return "\n".join([painted + lines[0]]
+ [indent + line for line in lines[1:]])
def setup_logging(level: int = logging.INFO) -> bool:
"""Настраивает единый обработчик вывода. Возвращает, включены ли цвета."""
colored = enable_ansi()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(ConsoleFormatter(colored))
root = logging.getLogger()
root.handlers[:] = [handler]
root.setLevel(level)
return colored
+132
View File
@@ -0,0 +1,132 @@
"""Запуск сервиса: обновление, Caddyfile, HTTPS.
Раньше вся эта последовательность жила в .bat-файлах, а Caddyfile правился
руками. Оба файла лежат вне папки app, поэтому обновление их не доставляло:
чтобы получить новый порядок запуска, приходилось переустанавливать сервис.
Теперь .bat - это трёхстрочная заглушка, которая просто зовёт этот модуль,
а Caddyfile создаётся здесь из настроек. Меняется только код внутри app,
который обновление возит штатно.
"""
import subprocess
import sys
from pathlib import Path
__all__ = ["render_caddyfile", "ensure_caddyfile", "main"]
# По этой строке отличаем свой файл от отредактированного вручную.
MARK = "# Создан автоматически talkscore-asr. Правки будут перезаписаны."
CADDY_WAIT_SEC = 3
def render_caddyfile(domains: str, port: int, max_upload_mb: int) -> str:
"""Собирает конфигурацию Caddy из настроек сервиса."""
listed = ", ".join(d.strip() for d in domains.split(",") if d.strip())
# Пустой строки перед блоком быть не должно: caddy fmt считает это
# неформатированным файлом и ругается при каждом запуске.
return f"""{MARK}
# Домены и предел размера берутся из config.toml, менять нужно там.
{{
\t# Caddy очень подробно рассказывает про сертификаты. В консоли из-за этого
\t# не видно сообщений сервиса, поэтому его вывод уходит в файл.
\tlog {{
\t\toutput file logs/caddy.log {{
\t\t\troll_size 10MiB
\t\t\troll_keep 5
\t\t}}
\t\tformat console
\t\tlevel INFO
\t}}
}}
{listed} {{
\t# X-Forwarded-For здесь намеренно не задаётся: Caddy сам игнорирует этот
\t# заголовок из входящего запроса и подставляет реальный адрес клиента.
\t# Не добавляйте сюда trusted_proxies - тогда Caddy начнёт верить заголовку
\t# от клиента, и любой сможет назваться разрешённым адресом.
\treverse_proxy 127.0.0.1:{port}
\trequest_body {{
\t\tmax_size {max_upload_mb}MB
\t}}
}}
"""
def ensure_caddyfile(base_dir: Path, content: str) -> Path | None:
"""Записывает Caddyfile, не затирая ручные правки.
Если файл создан не нами, откладываем новую версию рядом: молча стереть
чужую настройку хуже, чем оставить её устаревшей.
"""
target = base_dir / "Caddyfile"
if target.exists():
existing = target.read_text(encoding="utf-8", errors="replace")
if MARK not in existing:
spare = base_dir / "Caddyfile.new"
if existing.strip() != content.strip():
spare.write_text(content, encoding="utf-8")
return spare
return None
if existing == content:
return None
target.write_text(content, encoding="utf-8")
return target
def start_caddy(base_dir: Path) -> subprocess.Popen | None:
"""Поднимает Caddy отдельным процессом. Его отсутствие не должно ронять запуск."""
binary = base_dir / "bin" / "caddy.exe"
if not binary.is_file():
binary = base_dir / "bin" / "caddy"
if not binary.is_file():
print("\n Caddy не найден в папке bin - запускаюсь без HTTPS.\n")
return None
(base_dir / "logs").mkdir(exist_ok=True)
return subprocess.Popen(
[str(binary), "run", "--config", str(base_dir / "Caddyfile")],
cwd=str(base_dir))
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
https = "https" in argv
from app import updater
from app.config import ConfigError, load_settings
try:
settings = load_settings()
except ConfigError as exc:
print(f"\n {exc}\n")
return 1
updater.main()
caddy = None
if https:
if not settings.https_domains.strip():
print("\n В config.toml не заданы домены: раздел [https], ключ domains."
"\n Без них сертификат выпустить не на что.\n")
return 1
written = ensure_caddyfile(
settings.base_dir,
render_caddyfile(settings.https_domains, settings.port,
settings.max_upload_mb))
if written is not None and written.name.endswith(".new"):
print(f"\n Caddyfile правили вручную, поэтому не трогаю его."
f"\n Новая версия отложена рядом: {written}\n")
caddy = start_caddy(settings.base_dir)
try:
from app.main import run
run()
finally:
if caddy is not None:
caddy.terminate()
return 0
if __name__ == "__main__":
sys.exit(main())
+58
View File
@@ -0,0 +1,58 @@
"""Кольцевой буфер последних записей журнала.
Нужен, чтобы смотреть логи через API, а не копировать их из окна консоли.
Хранится в памяти: файл на диске пришлось бы чистить, а история глубже
последних сотен строк для разбора сбоя не нужна.
"""
import logging
import threading
from collections import deque
__all__ = ["LogBuffer", "install"]
class LogBuffer(logging.Handler):
def __init__(self, capacity: int = 500):
super().__init__()
self._records: deque = deque(maxlen=capacity)
self._lock = threading.Lock()
def emit(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage()
if record.exc_info:
message += "\n" + self.format(record).split("\n", 1)[-1]
except Exception: # noqa: BLE001 - журнал не должен падать сам
message = "не удалось разобрать запись журнала"
with self._lock:
self._records.append({
"time": record.created,
"level": record.levelname,
"logger": record.name,
"message": message,
})
def tail(self, limit: int = 100, level: str | None = None) -> list[dict]:
with self._lock:
rows = list(self._records)
if level:
wanted = level.upper()
rows = [r for r in rows if r["level"] == wanted]
return rows[-limit:]
_buffer: LogBuffer | None = None
def install(capacity: int = 500) -> LogBuffer:
"""Подключает буфер к корневому журналу. Повторный вызов вернёт тот же буфер."""
global _buffer
if _buffer is None:
_buffer = LogBuffer(capacity)
_buffer.setFormatter(logging.Formatter("%(message)s"))
logging.getLogger().addHandler(_buffer)
return _buffer
def get() -> LogBuffer:
return install()
+303 -45
View File
@@ -1,70 +1,120 @@
"""HTTP-сервис распознавания: приём файла, очередь, выдача результата."""
import logging
import os
import shutil
import socket
import sys
import tempfile
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from app.config import Settings, load_settings
from app.config import ConfigError, Settings, load_settings
from app.console import setup_logging
from app.logbuffer import get as log_buffer
from app.logbuffer import install as install_log_buffer
from app.pipeline import ModelsMissing, Pipeline, to_wav16k
from app.security import check_token, ip_allowed, parse_allowlist
from app.security import (check_token, client_address, ip_allowed,
parse_allowlist, token_matches)
from app.store import JobStatus, JobStore
from app.webhook import deliver_async
from app.version import __version__
log = logging.getLogger("talkscore-asr")
settings: Settings = load_settings()
try:
settings: Settings = load_settings()
except ConfigError as exc:
# Печатаем понятную причину и выходим: со сломанным конфигом
# сервис всё равно ничего осмысленного не сделает.
print(f"\n {exc}\n")
raise SystemExit(1) from None
allowlist = parse_allowlist(settings.allow_ips)
store = JobStore(settings.data_dir / "jobs.db")
pipeline = Pipeline(
models_dir=settings.models_dir,
threads=settings.effective_threads(),
replacements_path=settings.replacements_path,
base_dir=settings.base_dir,
)
trusted_proxies = parse_allowlist(settings.trust_proxy)
# Хранилище создаётся не здесь, а при старте сервиса. На Windows процессы
# поднимаются через spawn и заново импортируют этот модуль: если бы очередь
# открывалась на уровне модуля, каждый новый процесс при старте помечал бы
# чужие выполняющиеся задачи как сорванные.
store: JobStore = None # type: ignore[assignment]
def make_pipeline() -> Pipeline:
"""Своя копия моделей на каждого воркера: они не рассчитаны на общий доступ."""
return Pipeline(
models_dir=settings.models_dir,
threads=settings.effective_threads(),
replacements_path=settings.replacements_path,
base_dir=settings.base_dir,
)
pipeline = make_pipeline()
_worker_stop = threading.Event()
_state: dict = {"ready": False, "error": None}
def _process(job_id: str) -> None:
def _process(job_id: str, worker: Pipeline, pool) -> None:
"""Задача уже помечена в работе тем, кто её забрал."""
job = store.get(job_id)
if job is None:
return
store.mark_running(job_id)
upload = settings.data_dir / "uploads" / job_id
try:
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "audio.wav"
to_wav16k(upload, wav, pipeline.ffmpeg)
speakers = int(job["options"].get("speakers", settings.speakers))
result = pipeline.transcribe(wav, num_speakers=speakers)
speakers = int(job["options"].get("speakers", settings.speakers))
if pool is not None:
# Считаем в отдельном процессе: библиотеки держат GIL, и в потоках
# задачи выстраиваются в очередь вместо параллельной работы.
from app.worker import run_job
result = pool.submit(run_job, str(upload), speakers, worker.ffmpeg,
settings.normalize).result()
else:
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "audio.wav"
to_wav16k(upload, wav, worker.ffmpeg, settings.normalize)
result = worker.transcribe(wav, num_speakers=speakers)
result["filename"] = job["filename"]
store.mark_done(job_id, result)
log.info("задача %s готова: %.1f с аудио, x%s", job_id,
result["duration_sec"], result["timing"]["realtime_factor"])
_notify(job, {"job_id": job_id, "status": JobStatus.DONE, **result})
except Exception as exc: # noqa: BLE001 - в статус задачи должна попасть любая причина
log.exception("задача %s провалилась", job_id)
store.mark_failed(job_id, error=f"{type(exc).__name__}: {exc}")
error = f"{type(exc).__name__}: {exc}"
store.mark_failed(job_id, error=error)
_notify(job, {"job_id": job_id, "status": JobStatus.FAILED,
"filename": job["filename"], "error": error})
finally:
upload.unlink(missing_ok=True)
def _worker_loop() -> None:
"""Один воркер: модели тяжёлые, параллельные задачи только мешали бы друг другу."""
def _notify(job: dict, payload: dict) -> None:
"""Сообщает о результате, если для задачи задан адрес."""
url = job["options"].get("webhook") or settings.webhook_url
if url:
deliver_async(url, payload, settings.webhook_secret)
def _worker_loop(index: int, worker: Pipeline, pool=None) -> None:
"""Разбирает очередь. Задача захватывается атомарно, поэтому воркеров может быть много."""
last_cleanup = 0.0
while not _worker_stop.is_set():
if _state["ready"]:
job_id = store.take_next()
job_id = store.claim_next()
if job_id:
_process(job_id)
log.info("воркер %d взял задачу %s", index, job_id)
_process(job_id, worker, pool)
continue
if time.time() - last_cleanup > 3600:
# Уборкой занимается только первый воркер, чтобы не делать её хором.
if index == 0 and time.time() - last_cleanup > 3600:
removed = store.cleanup(settings.keep_results_hours)
if removed:
log.info("удалено старых задач: %d", removed)
@@ -72,39 +122,101 @@ def _worker_loop() -> None:
_worker_stop.wait(1.0)
def _make_pool(count: int):
"""Пул процессов для расчётов. При неудаче работаем в одном процессе.
На Windows процессы поднимаются через spawn, и сбои тут возможны, поэтому
отказ не должен ронять сервис - он просто станет однопроцессным.
"""
from concurrent.futures import ProcessPoolExecutor
from app.worker import init_worker
try:
pool = ProcessPoolExecutor(
max_workers=count,
initializer=init_worker,
initargs=(str(settings.models_dir), settings.effective_threads(),
str(settings.replacements_path), str(settings.base_dir)),
)
# Пустая задача проверяет, что процессы действительно поднялись
pool.submit(str, "ok").result(timeout=300)
return pool
except Exception as exc: # noqa: BLE001 - причин может быть много, важен откат
log.warning("не удалось запустить процессы (%s), работаю в одном", exc)
return None
@asynccontextmanager
async def lifespan(app: FastAPI):
global store
(settings.data_dir / "uploads").mkdir(parents=True, exist_ok=True)
store = JobStore(settings.data_dir / "jobs.db")
try:
pipeline.warmup()
_state["ready"] = True
except ModelsMissing as exc:
_state["error"] = str(exc)
log.error("сервис запущен без моделей: %s", exc)
worker = threading.Thread(target=_worker_loop, name="asr-worker", daemon=True)
worker.start()
log.info("сервис слушает %s:%s, потоков %d", settings.host, settings.port,
settings.effective_threads())
count = settings.effective_workers()
pool = None
if count > 1 and _state["ready"]:
pool = _make_pool(count)
workers = []
for i in range(count):
worker = pipeline if i == 0 else make_pipeline()
thread = threading.Thread(target=_worker_loop, args=(i, worker, pool),
name=f"asr-worker-{i}", daemon=True)
thread.start()
workers.append(thread)
log.info("сервис слушает %s:%s, воркеров %d по %d потоков, режим %s",
settings.host, settings.port, count, settings.effective_threads(),
"процессы" if pool else "один процесс")
yield
_worker_stop.set()
worker.join(timeout=5)
for thread in workers:
thread.join(timeout=5)
if pool is not None:
pool.shutdown(wait=False, cancel_futures=True)
# Схема API не требует токена, поэтому по умолчанию она не публикуется:
# знать устройство сервиса посторонним незачем.
# Штатные /docs и /openapi.json отключены: они никого не проверяют. Вместо них
# ниже свои маршруты - выключенные по умолчанию и закрытые адресом и токеном.
app = FastAPI(
title="talkscore-asr",
version="0.1.0",
version=__version__,
description=(
"Локальное распознавание русской речи с разделением по говорящим.\n\n"
"Все методы, кроме `/health`, требуют заголовок `Authorization: Bearer <токен>`. "
"Нажмите **Authorize** и вставьте токен из `config.toml`."
),
lifespan=lifespan,
docs_url="/docs" if settings.docs else None,
redoc_url="/redoc" if settings.docs else None,
openapi_url="/openapi.json" if settings.docs else None,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
bearer_scheme = HTTPBearer(description="Токен из config.toml, раздел [security]")
def peer_address(request: Request) -> str | None:
"""Адрес клиента с поправкой на обратный прокси."""
peer = request.client.host if request.client else None
return client_address(peer, request.headers.get("x-forwarded-for"), trusted_proxies)
def ip_guard(request: Request) -> None:
"""Только проверка адреса: нужна для страницы с описанием методов."""
client_ip = peer_address(request)
if not ip_allowed(client_ip, allowlist):
raise HTTPException(status_code=403, detail="адрес не в списке разрешённых")
def guard(request: Request) -> None:
"""Проверяет адрес и токен. Порядок важен: сначала сеть, потом секрет."""
client_ip = request.client.host if request.client else None
client_ip = peer_address(request)
if not ip_allowed(client_ip, allowlist):
log.warning("отказано по адресу: %s", client_ip)
raise HTTPException(status_code=403, detail="адрес не в списке разрешённых")
@@ -112,29 +224,109 @@ def guard(request: Request) -> None:
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен")
@app.get("/health")
def health() -> JSONResponse:
"""Проверка живости - без токена, чтобы годилась для мониторинга."""
def docs_guard(request: Request) -> str:
"""Пускает к документации и возвращает токен для ссылки на схему.
Токен принимается и заголовком, и параметром ?token=. Браузер, открывая
страницу по ссылке, заголовок не подставит, а документация без браузера
теряет смысл. Раньше здесь проверялся только адрес - с выключенным
списком адресов это означало открытый доступ.
"""
if not settings.docs:
# 404, а не 403: подтверждать существование страницы незачем.
raise HTTPException(status_code=404, detail="Not Found")
ip_guard(request)
supplied = request.query_params.get("token")
if check_token(request.headers.get("authorization"), settings.token):
return supplied or ""
if token_matches(supplied, settings.token):
return supplied or ""
raise HTTPException(status_code=401, detail="неверный или отсутствующий токен")
@app.get("/docs", include_in_schema=False)
def docs_page(request: Request):
"""Описание методов для браузера. Требует адрес из списка и токен."""
token = docs_guard(request)
# Схему Swagger запрашивает сам, уже без заголовка - протаскиваем токен
# в адрес, иначе страница откроется и тут же покажет ошибку доступа.
suffix = f"?token={quote(token)}" if token else ""
return get_swagger_ui_html(openapi_url=f"openapi.json{suffix}",
title="talkscore-asr")
@app.get("/openapi.json", include_in_schema=False)
def openapi_schema(request: Request) -> JSONResponse:
docs_guard(request)
schema = get_openapi(title=app.title, version=app.version,
description=app.description, routes=app.routes)
schema["components"] = schema.get("components", {})
schema["components"]["securitySchemes"] = {
"bearer": {"type": "http", "scheme": "bearer"}}
schema["security"] = [{"bearer": []}]
return JSONResponse(schema)
@app.get("/v1/logs", dependencies=[Depends(guard)], summary="Журнал сервиса",
description="Последние записи журнала: удобно посмотреть причину сбоя, "
"не заходя на машину. Уровень можно отфильтровать параметром level.")
def logs(limit: int = Query(100, ge=1, le=500),
level: str | None = Query(None, description="INFO, WARNING или ERROR")) -> dict:
rows = log_buffer().tail(limit=limit, level=level)
return {
"count": len(rows),
"records": [
{"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["time"])),
"level": r["level"], "logger": r["logger"], "message": r["message"]}
for r in rows
],
}
@app.get("/health", summary="Состояние сервиса",
description="Единственный метод без токена - годится для мониторинга. "
"Показывает версию, очередь, число потоков и то, каким сервис "
"видит ваш адрес.")
def health(request: Request) -> JSONResponse:
"""Проверка живости - без токена, чтобы годилась для мониторинга.
Показывает адрес обратившегося и вердикт по списку доступа: без этого
отладка отказов превращается в гадание, а сам адрес клиенту и так известен.
Список разрешённых адресов при этом не раскрывается.
"""
client_ip = peer_address(request)
return JSONResponse({
"status": "ok" if _state["ready"] else "no_models",
"version": __version__,
"error": _state["error"],
"queue": store.stats(),
"threads": settings.effective_threads(),
"workers": settings.effective_workers(),
"your_ip": client_ip,
"your_ip_allowed": ip_allowed(client_ip, allowlist),
"ip_filter_active": bool(allowlist),
})
@app.post("/v1/jobs", dependencies=[Depends(guard)])
@app.post("/v1/jobs", dependencies=[Depends(guard)], summary="Отправить запись",
description="Принимает аудиофайл любого формата, который читает ffmpeg: "
"mp3, wav, m4a, ogg, opus, wma. Возвращает идентификатор задачи - "
"результат забирается отдельным запросом, потому что обработка "
"занимает минуты.")
async def create_job(
file: UploadFile = File(...),
speakers: int | None = Query(None, ge=0, le=10,
description="число говорящих, 0 = определить автоматически"),
webhook: str | None = Query(None,
description="куда сообщить о готовности; заменяет адрес из настроек"),
) -> dict:
if not _state["ready"]:
raise HTTPException(status_code=503, detail=_state["error"] or "сервис ещё не готов")
job_id = store.create(filename=file.filename or "audio",
duration_sec=0.0,
options={"speakers": settings.speakers if speakers is None else speakers})
options={"speakers": settings.speakers if speakers is None else speakers,
"webhook": webhook or ""})
target = settings.data_dir / "uploads" / job_id
size = 0
try:
@@ -158,7 +350,35 @@ async def create_job(
"queue_position": store.queue_position(job_id)}
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
@app.post("/v1/benchmark", dependencies=[Depends(guard)], summary="Подобрать число потоков",
description="Прогоняет минуту из присланной записи на разном числе потоков "
"и показывает, где быстрее. Занимает пару минут и на это время "
"нагружает процессор.")
async def benchmark(file: UploadFile = File(...)) -> dict:
from app.bench import run_benchmark
from app.pipeline import read_wav
if not _state["ready"]:
raise HTTPException(status_code=503, detail=_state["error"] or "сервис ещё не готов")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / (file.filename or "audio")
with src.open("wb") as out:
while chunk := await file.read(1 << 20):
out.write(chunk)
wav = Path(tmp) / "audio.wav"
to_wav16k(src, wav, pipeline.ffmpeg, settings.normalize)
samples = read_wav(wav)
return run_benchmark(samples, models_dir=settings.models_dir,
base_dir=settings.base_dir,
replacements_path=settings.replacements_path)
@app.get("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Забрать результат",
description="Пока задача не готова, возвращает статус и место в очереди. "
"Готовый результат содержит реплики с говорящими и тайм-кодами, "
"статистику разговора и время обработки.")
def get_job(job_id: str) -> dict:
job = store.get(job_id)
if job is None:
@@ -174,7 +394,9 @@ def get_job(job_id: str) -> dict:
return body
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)])
@app.delete("/v1/jobs/{job_id}", dependencies=[Depends(guard)], summary="Удалить задачу",
description="Убирает задачу и её результат. Завершённые задачи и так "
"удаляются сами через срок из keep_results_hours.")
def delete_job(job_id: str) -> dict:
if store.get(job_id) is None:
raise HTTPException(status_code=404, detail="задача не найдена")
@@ -191,12 +413,36 @@ def _setup_console() -> None:
pass
def _port_busy(host: str, port: int) -> bool:
"""Проверяет, слушает ли кто-то уже этот порт.
Проверяем подключением, а не пробной привязкой: привязка оставляет сокет
в TIME_WAIT и может сама помешать запуску через секунду.
"""
target = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
with socket.socket() as probe:
probe.settimeout(0.5)
try:
return probe.connect_ex((target, port)) == 0
except OSError:
return False
def run() -> None:
import uvicorn
_setup_console()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
install_log_buffer()
setup_logging(logging.INFO)
if _port_busy(settings.host, settings.port):
# Uvicorn напишет про это «[Errno 10048] ... bind on address», из чего
# неочевидно, что виноват уже работающий экземпляр сервиса.
print(f"\n Порт {settings.port} уже занят - похоже, сервис уже запущен."
"\n Проверьте свёрнутые окна talkscore-asr либо найдите процесс:"
f"\n netstat -ano | findstr :{settings.port}"
"\n taskkill /PID <номер из последней колонки> /F\n")
raise SystemExit(1)
if not settings.token:
print("\n В config.toml пустой токен - сервис никого не пустит."
@@ -208,11 +454,23 @@ def run() -> None:
print("\n Модели не найдены. Сначала запустите download_models.bat"
"\n Сервис поднимется, но принимать записи не сможет.\n")
nets = len(allowlist)
access = f"только с {nets} адресов" if nets else "со ВСЕХ адресов"
print(f"\n Настройки: {settings.base_dir / 'config.toml'}"
f"\n Токен: {'задан' if settings.token else 'НЕ ЗАДАН, сервис никого не пустит'}"
f"\n Доступ: {access}"
f"\n Обработка: {settings.effective_workers()} задач одновременно"
f" по {settings.effective_threads()} потока")
print(f"\n talkscore-asr слушает http://{settings.host}:{settings.port}"
f"\n Потоков: {settings.effective_threads()}"
f"\n Проверка: curl http://localhost:{settings.port}/health"
"\n Остановить: Ctrl+C\n")
uvicorn.run(app, host=settings.host, port=settings.port, log_level="info")
# log_config=None отключает собственную настройку логов uvicorn. Иначе он
# заводит свои обработчики с propagate=False, его строки идут мимо нашего
# формата и рвутся посреди слова, а наши выглядят иначе - в одном окне два
# разных формата.
uvicorn.run(app, host=settings.host, port=settings.port, log_level="info",
log_config=None)
if __name__ == "__main__":
+116 -12
View File
@@ -12,14 +12,27 @@ from pathlib import Path
import numpy as np
from app.text import apply_replacements, load_replacements, normalize_typography
from app.acoustics import segment_acoustics, separation_quality
from app.text import (apply_replacements, load_replacements,
normalize_typography, split_sentences)
from app.turns import Segment, chunk_ranges, merge_turns, speech_stats
log = logging.getLogger(__name__)
SAMPLE_RATE = 16000
# GigaAM падает на кусках длиннее ~200 с, держим запас.
MAX_CHUNK_SEC = 150.0
# Предел зависит от сборки onnxruntime: на macOS реплика в 190 с проходит,
# на Windows та же падает в свёртке энкодера. Берём с большим запасом, а на
# случай сюрпризов есть деление куска пополам при ошибке.
MAX_CHUNK_SEC = 60.0
# Ниже этого делить бессмысленно: явно не длина виновата.
MIN_SPLIT_SEC = 2.0
# Модель отпечатков рассчитана на короткий фрагмент и на длинном падает.
# Для оценки голоса секунд более чем достаточно.
EMBED_SEC = 8.0
# Ниже этого значения голоса практически неразличимы и разметка по говорящим
# случайна. На записях с одним микрофоном в комнате так бывает часто.
RELIABLE_SEPARATION = 0.35
ASR_MODEL_NAME = "gigaam-v3-e2e-rnnt"
ASR_MODEL_REL = Path("gigaam")
SEG_MODEL_REL = Path("sherpa-onnx-pyannote-segmentation-3-0") / "model.onnx"
@@ -42,13 +55,22 @@ def find_ffmpeg(base_dir: Path) -> str:
return found
def to_wav16k(src: Path, dst: Path, ffmpeg: str) -> float:
# Выравнивание громкости - решающий шаг для записей с одним микрофоном на столе.
# На проверочных разговорах доля второго участника выросла с 1.8 до 24.3 процента,
# а переключений между репликами - с 21 до 71 процента. Распознанный текст при
# этом не меняется: сравнение фрагментов дало полностью совпадающие расшифровки.
# Параметры подобраны замером: f=400:g=3 даёт лишь 8.6 процента вместо 43.2,
# так что менять их наугад не стоит.
NORMALIZE_FILTER = "dynaudnorm=f=200:g=5"
def to_wav16k(src: Path, dst: Path, ffmpeg: str, normalize: bool = True) -> float:
"""Приводит любой аудиофайл к 16 кГц моно WAV. Возвращает длительность."""
subprocess.run(
[ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src),
"-vn", "-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)],
check=True, capture_output=True,
)
command = [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src), "-vn"]
if normalize:
command += ["-af", NORMALIZE_FILTER]
command += ["-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)]
subprocess.run(command, check=True, capture_output=True)
with wave.open(str(dst)) as w:
return w.getnframes() / w.getframerate()
@@ -70,11 +92,25 @@ class Pipeline:
self.models_dir = Path(models_dir)
self.threads = threads
self.replacements_path = Path(replacements_path)
self.ffmpeg = find_ffmpeg(Path(base_dir))
self.base_dir = Path(base_dir)
self._ffmpeg: str | None = None
self._asr = None
self._diar_cache: dict[int, object] = {}
self._embedder = None
self._replacements: dict[str, str] = {}
@property
def ffmpeg(self) -> str:
"""Ищется при первом обращении, а не в конструкторе.
Иначе отсутствие ffmpeg роняло бы сам импорт модуля - и вместо внятного
сообщения пользователь получал бы стек при старте, а обновление кода
не могло бы проверить работоспособность новой версии.
"""
if self._ffmpeg is None:
self._ffmpeg = find_ffmpeg(self.base_dir)
return self._ffmpeg
def warmup(self) -> None:
"""Грузит модели заранее, чтобы первый запрос не ждал."""
import onnx_asr
@@ -135,10 +171,62 @@ class Pipeline:
self._diar_cache[num_speakers] = diar
return diar
def _separation_quality(self, samples: np.ndarray, segments: list[Segment]) -> float:
"""Оценивает, действительно ли голоса разошлись, или разметка случайна."""
import sherpa_onnx
usable = [s for s in segments if s.end - s.start >= 1.0][:120]
if len(usable) < 4:
return 0.0
if self._embedder is None:
self._embedder = sherpa_onnx.SpeakerEmbeddingExtractor(
sherpa_onnx.SpeakerEmbeddingExtractorConfig(
model=str(self.models_dir / EMB_MODEL_REL), num_threads=self.threads))
vectors, labels = [], []
for seg in usable:
# Берём кусок из середины реплики: там речь устойчивее, чем на краях,
# а длинный фрагмент модель отпечатков просто не переваривает.
middle = (seg.start + seg.end) / 2
half = min(EMBED_SEC, seg.end - seg.start) / 2
piece = samples[int((middle - half) * SAMPLE_RATE):int((middle + half) * SAMPLE_RATE)]
if len(piece) < SAMPLE_RATE // 2:
continue
stream = self._embedder.create_stream()
stream.accept_waveform(SAMPLE_RATE, piece)
stream.input_finished()
vectors.append(np.array(self._embedder.compute(stream)))
labels.append(seg.speaker)
if len(vectors) < 4:
return 0.0
return separation_quality(np.array(vectors), np.array(labels))
def _recognize_safely(self, audio: np.ndarray, depth: int = 0) -> str:
"""Распознаёт кусок, при ошибке деля его пополам.
Пределы длины у разных сборок onnxruntime разные и заранее неизвестны,
поэтому вместо подбора константы просто уменьшаем кусок и пробуем снова.
"""
try:
return self._asr.recognize(audio, sample_rate=SAMPLE_RATE)
except Exception:
seconds = len(audio) / SAMPLE_RATE
if seconds <= MIN_SPLIT_SEC or depth >= 5:
log.warning("не удалось распознать кусок в %.1f с, пропускаю", seconds)
return ""
log.info("кусок в %.1f с не прошёл, делю пополам", seconds)
middle = len(audio) // 2
left = self._recognize_safely(audio[:middle], depth + 1)
right = self._recognize_safely(audio[middle:], depth + 1)
return " ".join(part for part in (left, right) if part)
def transcribe(self, wav_path: Path, num_speakers: int = 2) -> dict:
"""Полный проход: диаризация, распознавание реплик, постобработка."""
"""Полный проход: диаризация, распознавание реплик, постобработка.
Модели грузятся при первой задаче, а не при создании: воркеров несколько,
и держать копии моделей у тех, кому работа ещё не досталась, незачем.
"""
if self._asr is None:
raise RuntimeError("модели не загружены, вызовите warmup()")
self.warmup()
self._reload_replacements()
samples = read_wav(wav_path)
@@ -147,6 +235,12 @@ class Pipeline:
t0 = time.time()
raw = self._diarizer(num_speakers).process(samples).sort_by_start_time()
segments = [Segment(start=s.start, end=s.end, speaker=s.speaker) for s in raw]
try:
quality = self._separation_quality(samples, segments)
except Exception as exc: # noqa: BLE001 - оценка вспомогательная
# Метрика не должна ронять задачу: без неё расшифровка всё равно нужна.
log.warning("не удалось оценить разделение говорящих: %s", exc)
quality = 0.0
t_diar = time.time() - t0
t0 = time.time()
@@ -157,24 +251,34 @@ class Pipeline:
audio = samples[int(start * SAMPLE_RATE):int(stop * SAMPLE_RATE)]
if len(audio) < SAMPLE_RATE * 0.2:
continue
pieces.append(self._asr.recognize(audio, sample_rate=SAMPLE_RATE))
pieces.append(self._recognize_safely(audio))
text = " ".join(p.strip() for p in pieces if p and p.strip())
if not text:
continue
text = normalize_typography(apply_replacements(text, self._replacements))
audio = samples[int(turn.start * SAMPLE_RATE):int(turn.end * SAMPLE_RATE)]
turns_out.append({
"speaker": turn.speaker + 1,
"start": round(turn.start, 2),
"end": round(turn.end, 2),
"text": text,
# Подсказка для LLM: у говорящего ближе к микрофону громкость
# и доля высоких выше. Когда разделение по голосу ненадёжно,
# это единственный объективный признак, кто есть кто.
"acoustics": segment_acoustics(audio),
})
t_asr = time.time() - t0
return {
"duration_sec": round(duration, 1),
"turns": turns_out,
# Готовый материал для разметки ролей на стороне Talkscore: делить
# реплики самостоятельно ей незачем, пунктуацию ставит ASR.
"sentences": split_sentences(turns_out),
"stats": {
**speech_stats(segments),
"separation_quality": quality,
"speakers_reliable": quality >= RELIABLE_SEPARATION,
"silence_sec": round(max(0.0, duration - sum(s.end - s.start for s in segments)), 1),
"turns_count": len(turns_out),
},
+36 -3
View File
@@ -3,7 +3,8 @@ import ipaddress
import secrets
from ipaddress import IPv4Network, IPv6Network
__all__ = ["check_token", "parse_allowlist", "ip_allowed"]
__all__ = ["check_token", "token_matches", "parse_allowlist", "ip_allowed",
"client_address"]
Network = IPv4Network | IPv6Network
@@ -15,12 +16,26 @@ def check_token(header_value: str | None, expected: str) -> bool:
иначе забытая настройка молча выставила бы сервис наружу.
Сравнение идёт в постоянное время, чтобы токен нельзя было подобрать по таймингам.
"""
if not expected or not header_value:
if not header_value:
return False
scheme, _, value = header_value.partition(" ")
if scheme.lower() != "bearer":
return False
return secrets.compare_digest(value.strip(), expected)
return token_matches(value, expected)
def token_matches(value: str | None, expected: str) -> bool:
"""Сверяет голое значение токена, без схемы Bearer.
Нужно для ссылок вида ?token=: браузер, открывая страницу по ссылке,
заголовок Authorization не подставит.
"""
if not expected or not value:
return False
# Сравниваем байты, а не строки: compare_digest на строках с не-ASCII
# бросает TypeError, и токен с кириллицей давал бы 500 вместо 401.
return secrets.compare_digest(value.strip().encode("utf-8"),
expected.encode("utf-8"))
def parse_allowlist(raw: str) -> list[Network]:
@@ -51,3 +66,21 @@ def ip_allowed(client_ip: str | None, allowlist: list[Network]) -> bool:
except ValueError:
return False
return any(addr in net for net in allowlist)
def client_address(peer_ip: str | None, forwarded: str | None,
trusted_proxies: list[Network]) -> str | None:
"""Возвращает адрес клиента с учётом обратного прокси.
Заголовку X-Forwarded-For верим только тогда, когда сам запрос пришёл
от доверенного прокси. Иначе любой желающий подставил бы себе разрешённый
адрес и обошёл ограничение.
"""
if not forwarded or not trusted_proxies or not ip_allowed(peer_ip, trusted_proxies):
return peer_ip
first = forwarded.split(",")[0].strip()
try:
ipaddress.ip_address(first)
except ValueError:
return peer_ip
return first
+11 -4
View File
@@ -82,13 +82,20 @@ class JobStore:
job["options"] = json.loads(job["options"]) if job["options"] else {}
return job
def take_next(self) -> str | None:
"""Возвращает id самой старой задачи в очереди, не меняя её статус."""
def claim_next(self) -> str | None:
"""Забирает самую старую задачу из очереди и сразу помечает её в работе.
Выборка и пометка выполняются одним оператором под общим замком: иначе
два воркера успевают увидеть одну и ту же задачу и берут её оба.
"""
with self._lock:
row = self._conn.execute(
"SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1",
(JobStatus.QUEUED,),
"UPDATE jobs SET status=?, started_at=? WHERE id = ("
" SELECT id FROM jobs WHERE status=? ORDER BY created_at LIMIT 1"
") RETURNING id",
(JobStatus.RUNNING, time.time(), JobStatus.QUEUED),
).fetchone()
self._conn.commit()
return row["id"] if row else None
def queue_position(self, job_id: str) -> int:
+37 -1
View File
@@ -7,7 +7,8 @@
import re
from pathlib import Path
__all__ = ["apply_replacements", "load_replacements", "normalize_typography"]
__all__ = ["apply_replacements", "load_replacements", "normalize_typography",
"split_sentences"]
# Все виды тире, которые встречаются в выводе моделей, включая минус.
_DASHES = "‐‑‒–—―−"
@@ -57,3 +58,38 @@ def normalize_typography(text: str) -> str:
text = _GLUED_QUOTE_RE.sub(r" \1", text)
text = _MULTISPACE_RE.sub(" ", text)
return text.strip()
# Конец предложения: точка, восклицательный, вопросительный или многоточие,
# после которых идёт пробел. Пунктуацию расставляет сама модель GigaAM.
_SENTENCE_END_RE = re.compile(r"(?<=[.!?…])\s+")
def split_sentences(turns: list[dict]) -> list[dict]:
"""Режет реплики на предложения для последующей разметки ролей.
Разделение по голосам на записях с одним микрофоном обычно не работает,
и роли расставляет LLM по смыслу текста. Ей нужны именно предложения:
в одну реплику диаризация складывает и вопрос одного человека, и ответ
другого, поэтому разметить реплику целиком нельзя.
Точного времени начала у предложения нет - внутри реплики оно делится
поровну. Для сборки диалога обратно этого достаточно, для точной привязки
к звуку - нет.
"""
out: list[dict] = []
for index, turn in enumerate(turns):
parts = [p.strip() for p in _SENTENCE_END_RE.split(turn["text"]) if p.strip()]
if not parts:
continue
span = (turn["end"] - turn["start"]) / len(parts)
loudness = (turn.get("acoustics") or {}).get("loudness_db")
for position, text in enumerate(parts):
out.append({
"n": len(out) + 1,
"text": text,
"start": round(turn["start"] + position * span, 2),
"loudness_db": loudness,
"turn": index,
})
return out
+89 -33
View File
@@ -9,6 +9,7 @@ Python, библиотеки, ffmpeg и модели остаются на ме
"""
import hashlib
import io
import os
import logging
import shutil
import subprocess
@@ -70,13 +71,19 @@ def _safe_members(archive: zipfile.ZipFile) -> list[str]:
return names
def _default_verify(base: Path) -> bool:
"""Проверяет, что обновлённый код хотя бы импортируется."""
def _default_verify(staging: Path) -> bool:
"""Проверяет, что новый код импортируется, ещё до его установки.
Код берётся из временной папки, рабочая при этом не тронута: если новая
версия окажется нерабочей, ставить её не начнём вовсе.
"""
env = dict(os.environ)
env.update({"PYTHONPATH": str(staging)})
env.setdefault("TALKSCORE_ASR_HOME", str(staging.parent))
try:
result = subprocess.run(
[sys.executable, "-c", "import app.main"],
cwd=str(base), capture_output=True, timeout=UPDATE_TIMEOUT_SEC,
env={"PYTHONPATH": str(base), "PATH": "", "TALKSCORE_ASR_HOME": str(base)},
cwd=str(staging), capture_output=True, timeout=UPDATE_TIMEOUT_SEC, env=env,
)
if result.returncode != 0:
log.error("новая версия не импортируется: %s",
@@ -89,48 +96,94 @@ def _default_verify(base: Path) -> bool:
def install_update(archive_bytes: bytes, base_dir: Path,
verify: Callable[[Path], bool] | None = None) -> None:
"""Ставит новую версию папки app с возможностью отката."""
"""Обновляет файлы внутри app, не трогая саму папку.
Windows не даёт переместить или удалить папку, из которой сейчас выполняется
код, а обновление запускается именно оттуда. Поэтому папка остаётся на месте,
а файлы в ней перезаписываются по одному, с копией для отката.
"""
base = Path(base_dir)
verify = verify or _default_verify
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
members = _safe_members(z)
staging = base / ".update-staging"
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
for name in members:
if Path(name).name in USER_FILES:
continue
z.extract(name, staging)
new_app = staging / "app"
if not new_app.is_dir():
shutil.rmtree(staging, ignore_errors=True)
raise UpdateError("в архиве нет папки app")
staging = base / ".update-staging"
backup = base / ".update-backup"
shutil.rmtree(backup, ignore_errors=True)
current = base / "app"
if current.exists():
shutil.move(str(current), str(backup))
live = base / "app"
try:
shutil.move(str(new_app), str(current))
if not verify(base):
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as z:
for name in _safe_members(z):
if Path(name).name in USER_FILES:
continue
z.extract(name, staging)
if not (staging / "app").is_dir():
raise UpdateError("в архиве нет папки app")
# Проверяем до установки: рабочая версия ещё цела и продолжит работать,
# если новая окажется сломанной.
if not verify(staging):
raise UpdateError("новая версия не прошла проверку запуска")
except Exception:
shutil.rmtree(current, ignore_errors=True)
if backup.exists():
shutil.move(str(backup), str(current))
raise
live.mkdir(parents=True, exist_ok=True)
shutil.rmtree(backup, ignore_errors=True)
shutil.copytree(live, backup)
try:
_sync_files(staging / "app", live)
except Exception:
_sync_files(backup, live)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(backup, ignore_errors=True)
def _sync_files(src: Path, dst: Path) -> None:
"""Приводит dst к состоянию src: копирует файлы и убирает лишние.
Устаревший модуль, удалённый в новой версии, обязан исчезнуть и здесь,
иначе он останется импортируемым и будет вести себя как призрак.
"""
dst.mkdir(parents=True, exist_ok=True)
wanted = set()
for item in sorted(src.rglob("*")):
rel = item.relative_to(src)
target = dst / rel
wanted.add(rel)
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
for item in sorted(dst.rglob("*"), reverse=True):
rel = item.relative_to(dst)
if rel in wanted or rel.parts[0] == "__pycache__":
continue
if item.is_file():
item.unlink(missing_ok=True)
elif item.is_dir() and not any(item.iterdir()):
item.rmdir()
def _api(session, url: str, token: str):
headers = {"Authorization": f"token {token}"} if token else {}
response = session.get(url, headers=headers, timeout=30)
# На приватном репозитории Gitea отвечает 404, а не 403: она скрывает сам
# факт его существования. Поэтому 404 почти всегда означает не «нет релиза»,
# а «этот токен сюда не пускают».
if response.status_code in (401, 403, 404):
if not token:
raise UpdateError(
"репозиторий не отдаётся без токена. Впишите его в config.toml, "
"раздел [update], поле token (Gitea: Settings - Applications - "
"Generate Token, право read:repository)")
raise UpdateError(
f"Gitea ответила {response.status_code}. Обычно это значит, что токен "
"в config.toml неверный или у него нет права read:repository")
response.raise_for_status()
return response
@@ -194,7 +247,10 @@ def main() -> int:
else:
print(" Установлена последняя версия")
except Exception as exc: # noqa: BLE001 - падение обновления не должно мешать работе
print(f" Обновиться не удалось ({type(exc).__name__}: {exc}), запускаюсь на текущей версии")
# Полный стек requests занимает пол-экрана и пугает, оставляем суть.
reason = str(exc).split("(Caused by")[0].strip()[:160] or type(exc).__name__
print(f" Обновиться не удалось: {reason}")
print(" Запускаюсь на текущей версии")
return 0
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.14.1"
+59
View File
@@ -0,0 +1,59 @@
"""Отправка готового результата на сторонний адрес.
Опрос статуса работает, но заставляет принимающую сторону дёргать сервис
каждые несколько секунд. Вебхук снимает это: сервис сам постучится, когда
задача готова.
"""
import hashlib
import hmac
import json
import logging
import threading
import time
__all__ = ["sign_payload", "deliver", "deliver_async"]
log = logging.getLogger(__name__)
# Задержки между попытками: сразу, через полминуты, через пять минут.
# Дольше ждать нет смысла - результат лежит в очереди и его можно забрать.
RETRY_DELAYS = (0, 30, 300)
TIMEOUT_SEC = 30
def sign_payload(body: bytes, secret: str) -> str:
"""Подпись тела запроса, чтобы принимающая сторона знала, что это мы."""
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
def deliver(url: str, payload: dict, secret: str = "",
delays: tuple = RETRY_DELAYS) -> bool:
"""Отправляет результат, повторяя при неудаче. Возвращает признак успеха."""
import requests
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json; charset=utf-8"}
if secret:
headers["X-Talkscore-Signature"] = sign_payload(body, secret)
for attempt, delay in enumerate(delays, start=1):
if delay:
time.sleep(delay)
try:
response = requests.post(url, data=body, headers=headers, timeout=TIMEOUT_SEC)
if response.status_code < 300:
log.info("вебхук доставлен по задаче %s", payload.get("job_id"))
return True
log.warning("вебхук: попытка %d, ответ %s", attempt, response.status_code)
except Exception as exc: # noqa: BLE001 - причина неважна, важна повторная попытка
log.warning("вебхук: попытка %d не удалась (%s)", attempt, exc)
log.error("вебхук не доставлен по задаче %s, результат остаётся в очереди",
payload.get("job_id"))
return False
def deliver_async(url: str, payload: dict, secret: str = "") -> None:
"""Отправляет в фоне: воркер не должен ждать чужой сервер."""
thread = threading.Thread(target=deliver, args=(url, payload, secret),
name="webhook", daemon=True)
thread.start()
+46
View File
@@ -0,0 +1,46 @@
"""Обработка задачи в отдельном процессе.
Замеры показали, что sherpa-onnx и onnxruntime держат GIL: две задачи в двух
потоках идут ровно столько же, сколько подряд (выигрыш 1.04x). Поэтому
процессор простаивает, а воркеры-потоки бесполезны. В процессах те же две
задачи дают 1.59x даже с загрузкой моделей в каждом.
Модуль намеренно не импортирует app.main: на Windows дочерний процесс
поднимается через spawn и заново импортирует модуль с функцией. Если бы
это был main, в каждом процессе поднимался бы ещё один веб-сервер.
"""
import logging
from pathlib import Path
from app.pipeline import Pipeline
log = logging.getLogger(__name__)
# Живёт внутри процесса-воркера: модели грузятся один раз на процесс.
_pipeline: Pipeline | None = None
def init_worker(models_dir: str, threads: int, replacements_path: str, base_dir: str) -> None:
"""Готовит процесс к работе: грузит модели один раз."""
global _pipeline
logging.basicConfig(level=logging.WARNING)
_pipeline = Pipeline(models_dir=Path(models_dir), threads=threads,
replacements_path=Path(replacements_path),
base_dir=Path(base_dir))
_pipeline.warmup()
def run_job(audio_path: str, num_speakers: int, ffmpeg: str,
normalize: bool = True) -> dict:
"""Переводит файл в WAV и распознаёт. Выполняется в процессе-воркере."""
import tempfile
from app.pipeline import to_wav16k
if _pipeline is None:
raise RuntimeError("процесс-воркер не инициализирован")
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "audio.wav"
to_wav16k(Path(audio_path), wav, ffmpeg, normalize)
return _pipeline.transcribe(wav, num_speakers=num_speakers)
+30 -2
View File
@@ -23,6 +23,10 @@ PY_URL = f"https://www.python.org/ftp/python/{PY_VERSION}/python-{PY_VERSION}-em
# LGPL-сборка: нам нужен только декодер аудио, GPL-компоненты не требуются.
FFMPEG_URL = ("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/"
"ffmpeg-master-latest-win64-lgpl.zip")
# Caddy сам получает и продлевает сертификаты Let's Encrypt - для машины без
# постоянного администратора это надёжнее ручной возни с сертификатами.
CADDY_URL = ("https://github.com/caddyserver/caddy/releases/download/v2.10.2/"
"caddy_2.10.2_windows_amd64.zip")
PACKAGES = [
"fastapi", "uvicorn", "python-multipart",
@@ -82,6 +86,16 @@ def add_ffmpeg(stage: Path) -> None:
(bin_dir / "ffmpeg.exe").write_bytes(z.read(name))
def add_caddy(stage: Path) -> None:
print("Caddy для HTTPS")
data = fetch(CADDY_URL)
bin_dir = stage / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(data)) as z:
name = next(n for n in z.namelist() if n.endswith("caddy.exe"))
(bin_dir / "caddy.exe").write_bytes(z.read(name))
def add_app(stage: Path) -> None:
print("Код сервиса")
shutil.copytree(ROOT / "app", stage / "app",
@@ -92,6 +106,7 @@ def add_app(stage: Path) -> None:
shutil.copy(src, stage / name)
write_bat(stage / "start.bat", START_BAT)
write_bat(stage / "download_models.bat", DOWNLOAD_BAT)
write_bat(stage / "start-https.bat", HTTPS_BAT)
(stage / "download_models.py").write_text(
(ROOT / "build" / "download_models.py").read_text(encoding="utf-8"), encoding="utf-8")
@@ -109,13 +124,25 @@ def write_bat(path: Path, text: str) -> None:
# chcp 65001 переводит консоль в UTF-8, и Python дальше пишет в той же кодировке.
# Без этой пары русский текст в выводе превращается в мусор.
# .bat - трёхстрочные заглушки: вся логика запуска живёт в app/launcher.py,
# который обновление доставляет штатно. Раньше логика была здесь, и чтобы
# изменить порядок запуска, требовалась переустановка.
# chcp 65001 переводит консоль в UTF-8, иначе русский текст станет мусором.
START_BAT = """@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" -m app.updater
"%~dp0python\\python.exe" -m app.main
"%~dp0python\\python.exe" -m app.launcher
pause
"""
HTTPS_BAT = """@echo off
chcp 65001 >nul
cd /d "%~dp0"
set "TALKSCORE_ASR_HOME=%~dp0"
set "PYTHONPATH=%~dp0"
"%~dp0python\\python.exe" -m app.launcher https
pause
"""
@@ -144,6 +171,7 @@ def main() -> int:
add_wheels(stage)
if not args.skip_ffmpeg:
add_ffmpeg(stage)
add_caddy(stage)
add_app(stage)
archive = OUT / "talkscore-asr-windows.zip"
+71
View File
@@ -0,0 +1,71 @@
# HTTPS для сервиса
Без него токен и расшифровки разговоров идут по сети открытым текстом.
Схема простая: Caddy держит сертификаты и HTTPS, сервис сидит на localhost
и наружу напрямую не смотрит.
## Что нужно от вас
1. **Открыть порты 80 и 443** на роутере и в брандмауэре Windows.
Порт 80 нужен Let's Encrypt для проверки владения доменом, 443 - для самой
работы. Сейчас оба закрыты.
2. **Проверить домены.** `asr.netranking.ru` уже указывает на 90.157.58.23.
`asr.talkscore.ru` пока не резолвится - добавьте A-запись на тот же адрес.
3. **Порт 8756 можно закрыть снаружи** - после перехода на HTTPS он больше
не нужен из интернета.
## Настройка
В `config.toml` поменяйте две строки:
```toml
[server]
# Сервис больше не смотрит наружу: снаружи стоит Caddy
host = "127.0.0.1"
[security]
# Доверяем заголовку с адресом клиента только от своего же прокси
trust_proxy = "127.0.0.1"
```
**Зачем `trust_proxy`.** За прокси все запросы приходят с localhost, и список
разрешённых адресов увидел бы вместо клиента саму машину - то есть пустил бы
кого угодно. Caddy передаёт настоящий адрес в заголовке, и сервис его читает,
но **только когда запрос пришёл от доверенного прокси**. Иначе заголовок
подделал бы любой желающий.
Домены прописаны в файле `Caddyfile` рядом со `start.bat`. Если понадобится
изменить список - правьте его.
## Запуск
Вместо `start.bat` запускайте **`start-https.bat`**: он поднимает сервис на
localhost и Caddy перед ним.
При первом запуске Caddy получит сертификаты - это занимает несколько секунд
и требует, чтобы порт 80 был доступен снаружи. Дальше он продлевает их сам,
напоминаний не нужно.
Проверка:
```
curl https://asr.netranking.ru/health
```
## Если сертификат не выдаётся
Caddy пишет причину в своё окно. Частые случаи:
- **порт 80 закрыт** - Let's Encrypt не может проверить домен;
- **домен не указывает на эту машину** - проверьте A-запись;
- **порт 80 занят** другим приложением (IIS, Skype) - освободите его.
Пока сертификата нет, сервис доступен только по `http://localhost:8756`
с самой машины.
## Автозапуск
```
schtasks /create /tn "talkscore-asr" /tr "C:\talkscore-asr\start-https.bat" ^
/sc onstart /ru SYSTEM /rl HIGHEST
```
+370
View File
@@ -0,0 +1,370 @@
# Интеграция Talkscore с локальным ASR-сервисом
Документ для агента, который будет дорабатывать Talkscore. Описывает, что
изменить на стороне Talkscore, чтобы использовать локальный сервис распознавания
`talkscore-asr` вместо облачного ASR.
## Что это за сервис
Локальный сервис на Windows-машине: принимает аудиофайл, возвращает расшифровку
с разделением по говорящим. Работает офлайн, аудио наружу не уходит.
- Распознавание: GigaAM v3 (SaluteDevices), русский язык, с пунктуацией
- Разделение говорящих: sherpa-onnx с моделями pyannote и NeMo TitaNet
- Скорость: около 60 минут записи за 2,5 минуты
Адрес и токен спросите у владельца: сервис закрыт списком разрешённых адресов,
поэтому сервер Talkscore нужно в этот список внести.
## Главное, что нужно изменить в Talkscore
### 1. Отключить предобработку аудио
Сейчас в Talkscore включена нормализация громкости (RMS до -20 dBFS) перед
отправкой в ASR. **Её нужно выключить** - сервис делает нормализацию сам,
причём более подходящую (`dynaudnorm`), и лишний проход только тратит ресурсы
сервера.
Замер на восьми разговорах: нормализация `dynaudnorm` даёт долю второго
участника 24,3 % против 3,5 % у нормализации RMS. Двойная обработка не ломает
результат, но и не улучшает его.
Что оставить включённым: ничего из блока предобработки не требуется. VAD и
шумоподавление сервису не нужны - у него свой VAD внутри диаризации.
### 2. Заменить вызов ASR
Было: отправка в облачный ASR и ожидание ответа.
Стало: постановка задачи и получение результата вебхуком.
```python
import requests
ASR_URL = "http://АДРЕС:8756"
ASR_TOKEN = "токен из config.toml сервиса"
def send_to_asr(file_path: str, call_id: str) -> str:
"""Ставит запись в очередь распознавания. Возвращает id задачи."""
with open(file_path, "rb") as f:
response = requests.post(
f"{ASR_URL}/v1/jobs",
headers={"Authorization": f"Bearer {ASR_TOKEN}"},
params={
"speakers": 2,
"webhook": f"https://talkscore.ru/api/asr-callback?call_id={call_id}",
},
files={"file": f},
timeout=300,
)
response.raise_for_status()
return response.json()["job_id"]
```
### 3. Принять результат вебхуком
Сервис сам постучится, когда задача готова. Подпись тела лежит в заголовке
`X-Talkscore-Signature`, секрет задаётся в настройках сервиса.
```python
import hashlib
import hmac
WEBHOOK_SECRET = "тот же секрет, что в config.toml сервиса"
def asr_callback(request):
signature = request.headers.get("X-Talkscore-Signature", "")
expected = hmac.new(WEBHOOK_SECRET.encode(), request.body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return 403
payload = request.json()
if payload["status"] != "done":
# обработать ошибку: payload["error"]
return 200
save_transcript(payload)
return 200
```
Если вебхук не дошёл (три попытки: сразу, через 30 секунд, через 5 минут),
результат остаётся в сервисе и его можно забрать опросом:
`GET /v1/jobs/{job_id}` с тем же заголовком авторизации.
## Формат результата
```json
{
"job_id": "fd47ba135eae429cbf37fb6ec1d8c34c",
"status": "done",
"filename": "call.mp3",
"duration_sec": 1003.0,
"turns": [
{
"speaker": 1,
"start": 7.2,
"end": 9.4,
"text": "Ну, давайте послушаю вас ещё.",
"acoustics": {"loudness_db": -18.4, "hf_ratio": 0.208, "centroid_hz": 1706}
}
],
"stats": {
"speakers": 2,
"speech_sec": 557.0,
"silence_sec": 446.1,
"turns_count": 137,
"separation_quality": 0.27,
"speakers_reliable": false,
"by_speaker": [
{"speaker": 1, "speech_sec": 236.8, "share_pct": 42.5},
{"speaker": 2, "speech_sec": 320.2, "share_pct": 57.5}
]
},
"sentences": [
{"n": 1, "text": "Ну, давайте послушаю вас ещё.", "start": 7.2,
"loudness_db": -18.4, "turn": 0}
],
"timing": {"diarization_sec": 29.1, "asr_sec": 9.7, "realtime_factor": 25.9}
}
```
Поле `sentences` - готовый материал для разметки ролей: реплики уже разрезаны
по границам предложений, пронумерованы сквозной нумерацией и снабжены громкостью
из своей реплики. Делить текст самостоятельно не нужно. `turn` - номер реплики
в массиве `turns`, по нему собирают диалог обратно.
## Самое важное: полю `speaker` доверять нельзя
Записи делаются одним микрофоном на столе, оба участника в одной акустике.
Проверка на восьми разговорах: **разделение по голосам сработало только на одном
из восьми**. На остальных один участник получал от 91 до 99 процентов речи,
то есть модель просто не различает голоса.
Что с этим делать:
1. **Смотрите на `stats.speakers_reliable`.** Если `false` (а это обычный случай),
разметку по говорящим нужно строить заново - по смыслу реплик.
2. **Роли определяет LLM.** Готовый промпт - в разделе ниже.
3. **Границы реплик и тайм-коды достоверны** - их даёт детектор речи, и он
работает хорошо. Опираться можно на них, а не на номер говорящего.
4. **`acoustics` - подсказка.** У того, кто ближе к микрофону, громкость и доля
высоких частот стабильно выше. Это дополнительный сигнал для LLM.
## Промпт для восстановления ролей
Расставлять роли поверх реплик диаризации бесполезно: когда она провалилась,
в одной реплике оказываются и вопрос, и ответ. Поэтому текст режется на
предложения, а модель отвечает одной буквой на предложение - так выход
получается в десятки раз короче, чем при возврате текста.
Подготовка не нужна: сервис отдаёт готовый массив `sentences`. Соберите из него
список вида `1. Текст предложения` и подставьте в `{sentences}`, а число
предложений - в `{count}`.
```
Ты разбираешь запись разговора в автошколе: менеджер и клиент.
Микрофон стоял на столе, автоматическое разделение по голосам не сработало,
поэтому реплики склеены - в одной строке может быть и вопрос одного,
и ответ другого.
Ниже пронумерованные предложения по порядку. Определи для каждого, кто его
произнёс.
Как отличить:
- МЕНЕДЖЕР (M): рассказывает об условиях, ценах, документах, расписании;
отвечает на вопросы; предлагает записаться; говорит «у нас», «мы»,
«вам нужно принести», называет суммы и сроки.
- КЛИЕНТ (C): спрашивает про стоимость, сроки, расписание; рассказывает
о себе и своей ситуации; сомневается; сравнивает с другими автошколами;
соглашается или уточняет; говорит «а если», «мне нужно», «я слышал».
Подсказки:
- Вопрос и ответ на него принадлежат разным людям.
- Короткие «да», «ага», «понятно», «конечно» обычно принадлежат слушающему,
то есть тому, кто НЕ произносил предыдущее длинное объяснение.
- Менеджер говорит больше, но не непрерывно: клиент постоянно вставляет
короткие реплики.
Ответь строкой ровно из {count} символов, только M и C, без пробелов и переносов,
по одному символу на предложение в том же порядке.
Предложения:
{sentences}
```
После ответа склейте соседние предложения с одинаковой ролью обратно в реплики,
сохранив время начала первого предложения каждой группы.
Время внутри реплики делится между её предложениями поровну - точной привязки
к звуку у отдельного предложения нет. Для сборки диалога этого достаточно,
для перемотки к конкретной фразе - нет.
**Где делать этот шаг.** На стороне Talkscore, а не в ASR. Промпт будет меняться,
и переразметить сохранённые расшифровки дешевле, чем гонять аудио заново. К тому
же ASR остаётся локальным, бесплатным и детерминированным, а обращение к LLM -
платное и внешнее: недоступность OpenRouter не должна ронять распознавание.
## Словарь замен терминов
В сервисе лежит файл `replacements.txt` со словарём под автошколу: категории
прав, документы, госорганы, термины обучения и оплаты, частые ошибки
распознавания. Формат `что слышно = как надо`, замена по целым словам,
регистр не важен.
Файл перечитывается перед каждой задачей, перезапуск не нужен. Если в
расшифровках попадаются устойчивые ошибки - дописывайте строки туда.
## Замеры на восьми реальных записях
Общая длительность 205 минут (от 15 до 44 минут каждая), офисные разговоры
менеджера с клиентом, микрофон на столе.
### Время обработки
| Этап | Время на 205 минут | Доля |
|---|---|---|
| Разделение говорящих | 13,8 мин | 78 % |
| Распознавание речи | 3,7 мин | 21 % |
| Итого на машине с Ryzen 9 9950X | около 8,5 мин | - |
Разделение говорящих занимает почти всё время - распознавание в четыре раза
быстрее. На Ryzen при четырёх процессах по четыре потока выходит около
x24 к реальному времени: час записи за две с половиной минуты.
Разметка ролей через LLM добавляет 2-4 минуты на разговор - модель читает
весь текст целиком. Это делается параллельно и на пропускную способность
сервиса не влияет.
### Стоимость разметки ролей
Замеряно на записи в 15 минут (160 предложений). Обе проверенные модели -
рассуждающие, и по умолчанию они тратят на размышления тысячи токенов.
Это решающий фактор цены.
| Модель | Размышления | Выходных токенов | Время | Цена за 15 мин | За час |
|---|---|---|---|---|---|
| Claude Sonnet 5 | по умолчанию | 17 791 | 137 с | $0,188 | $0,75 |
| Claude Sonnet 5 | минимальные | 897 | 9 с | $0,019 | $0,076 |
| Claude Sonnet 5 | выключены | 375 | 7 с | $0,014 | $0,055 |
| Gemini 3.1 Pro | по умолчанию | 13 715 | 85 с | $0,172 | $0,69 |
| Gemini 3.1 Pro | минимальные | 5 354 | 32 с | $0,071 | $0,29 |
Отключение размышлений даёт разницу в тринадцать раз по цене и в двадцать
по времени. Но за это платится качеством: без размышлений модель верно
размечает начало разговора, а дальше начинает менять роли местами -
объяснения менеджера приписывает клиенту и наоборот.
Разумный компромисс - минимальные размышления: цена $0,076 за час против
$0,75 при полных, а разметка заметно устойчивее, чем при полностью
выключенных. Проверьте на своих записях, прежде чем ставить в поток.
У OpenRouter это задаётся полем запроса:
```json
{"model": "anthropic/claude-sonnet-5",
"reasoning": {"effort": "low"},
"messages": [...]}
```
### Половинная цена через batch
У OpenRouter есть отдельные модели с суффиксом `:batch` - вдвое дешевле, ответ
в течение часа. Для разбора завершившегося звонка это подходит: результат
мгновенно не нужен.
| Модель | Вход за 1 млн | Выход за 1 млн | 30-минутная запись |
|---|---|---|---|
| `anthropic/claude-sonnet-5` | $2,00 | $10,00 | $0,188 |
| `anthropic/claude-sonnet-5:batch` | $1,00 | $5,00 | **$0,094** |
Вводная цена Sonnet 5 действует до 31 августа 2026 года, после чего станет
$3 и $15 за миллион - все цифры вырастут в полтора раза.
Кэширование промпта здесь почти ничего не даёт: 95 % счёта - это токены
размышлений на выходе, а вход занимает около пяти тысяч токенов.
### Модели, которые не подошли
Проверены на той же записи тем же промптом. Экономия была бы большой, но
разметка неверная, а ради неё всё и делается.
| Модель | Итог |
|---|---|
| deepseek-v4-pro | ответа нет: весь лимит уходит в размышления |
| deepseek-v4-flash | формат держит, но склеивает вопрос с ответом |
| deepseek-v3.2 | сваливает почти всё на одного говорящего |
| deepseek-r1-0528 | упирается в потолок токенов, выдаёт мусор |
| claude-haiku-4.5 | в трёх режимах из четырёх зацикливается на `MCMCMC` |
Общая причина: все они не удерживают правило «вопрос и ответ принадлежат разным
людям» на дистанции в две с лишним сотни предложений. Справляются только
Sonnet 5 и Gemini 3.1 Pro.
Отдельно: этим моделям нельзя задавать большой `max_tokens` - они выбирают его
целиком на размышления и до ответа не доходят.
### Проверяйте формат ответа, а не только его наличие
Модель может вернуть пустую строку, прозу вместо букв, оборванный ответ или
зациклиться на `MCMCMC`. Если недостающие буквы молча дописывать, получится
правдоподобная на вид разметка из ничего. Проверяйте перед разбором:
- ответ непустой и состоит только из `M` и `C`;
- длина совпадает с числом предложений;
- `finish_reason` равен `stop`, а не `length`;
- доля смен говорящего не близка к 100 % - это признак зацикливания.
### Качество
**Разделение по голосам сработало на одной записи из восьми.** На остальных
один участник получал от 91 до 99 процентов речи.
| Запись | Доли говорящих | Оценка разделимости |
|---|---|---|
| 9272254c | 44 / 56 | 0,296 |
| 470e4858 | 91 / 9 | 0,256 |
| 02fef7a0 | 98 / 2 | 0,236 |
| 7444931c | 98 / 2 | 0,207 |
| 8dc00a27 | 99 / 1 | 0,310 |
| 6886a557 | 95 / 5 | 0,134 |
| ac20d666 | 1 / 99 | 0,429 |
| e287ae8b | 98 / 2 | 0,161 |
Для сравнения: на заведомо разных голосах оценка разделимости даёт 0,53,
на случайной разметке - 0,11. Все записи лежат в диапазоне 0,13-0,43.
**LLM восстанавливает диалог даже там, где разделение провалилось полностью.**
На записи с долями 98/2 Claude Sonnet 5 дал осмысленную структуру:
```
МЕНЕДЖЕР: Категорию Б обсуждали?
КЛИЕНТ: Категорию Б.
МЕНЕДЖЕР: Теорию Б как хотели изучить?
КЛИЕНТ: А-а, онлайн.
КЛИЕНТ: Что из себя представляет внутренний экзамен по теории?
МЕНЕДЖЕР: Вопросов допускается две ошибки...
```
Итог по разметке: 80 % реплик у менеджера, смена говорящего в 28 % переходов -
правдоподобно для консультации.
**Про Gemini 3.1 Pro.** На одной записи он вернул 100 % реплик менеджеру и ни
одной смены, то есть провалился полностью. На другой отработал нормально:
82 % менеджера, 32 % смен - на уровне Claude. Причина такого разброса не
установлена, поэтому в поток его ставить рискованно без дополнительной
проверки на своих данных.
**Передавать громкость реплик в промпт бесполезно**: с ней 81 % против 80 % без
неё, разницы нет. Экономьте токены и не передавайте.
## Что ещё стоит знать
- **Форматы**: принимается всё, что читает ffmpeg - mp3, wav, m4a, ogg, opus, wma.
- **Размер**: по умолчанию до 500 МБ, настраивается.
- **Очередь**: задачи обрабатываются по очереди, статус и место в очереди
видны в `GET /v1/jobs/{id}`.
- **Диагностика**: `GET /health` работает без токена, показывает версию,
очередь и то, каким сервис видит адрес обратившегося.
- **Журнал**: `GET /v1/logs?level=ERROR` - последние записи, чтобы разбирать
сбои не заходя на машину.
- **Описание методов**: `http://АДРЕС:8756/docs` в браузере, открывается
с разрешённых адресов.
+127 -26
View File
@@ -1,35 +1,136 @@
# Словарь замен: что модель слышит = как должно быть в тексте
# Словарь замен для расшифровок разговоров автошколы
#
# GigaAM обучена только на русском и коверкает английские термины предсказуемо -
# здесь это чинится. Регистр при поиске не важен, замена идёт только по целым
# словам, поэтому «лед» не тронет «лидер».
# GigaAM обучена только на русском и предсказуемо коверкает термины, аббревиатуры
# и английские слова. Здесь это чинится. Правила применяются к целым словам,
# регистр не важен: правило «лед = лид» не тронет слово «лидер».
#
# Файл перечитывается перед каждой задачей: правки применяются без перезапуска.
# Файл перечитывается перед каждой задачей - правки применяются без перезапуска.
# Порядок значения не имеет: длинные правила применяются раньше коротких.
# Инструменты и сервисы
гугл так менеджер = Google Tag Manager
гугл тэг менеджер = Google Tag Manager
google так менеджер = Google Tag Manager
google tack-менеджер = Google Tag Manager
так менеджер = Tag Manager
джава-скрипт = JavaScript
джаваскрипт = JavaScript
битрикс 24 = Битрикс24
амо црм = amoCRM
амоцрм = amoCRM
# ── Документы и категории ───────────────────────────────────────────────────
ву = В/У
в у = В/У
водительское удостоверение = водительское удостоверение
ноль три ву = 003 В/У
справка ноль три = справка 003 В/У
мед справка = медсправка
мед комиссия = медкомиссия
мед заключение = медзаключение
снилс = СНИЛС
инн = ИНН
категория а = категория A
категория б = категория B
категория бэ = категория B
категория в = категория B
категория це = категория C
категория эс = категория C
категория д = категория D
категория дэ = категория D
подкатегория а один = подкатегория A1
b категория = категория B
би категория = категория B
а категория = категория A
# Термины продаж и маркетинга
# ── Госорганы и системы ─────────────────────────────────────────────────────
гибдд = ГИБДД
гаи = ГАИ
агаи = ГАИ
мрэо = МРЭО
мреа = МРЭО
госуслуги = Госуслуги
гос услуги = Госуслуги
мфц = МФЦ
цодд = ЦОДД
# ── Обучение ────────────────────────────────────────────────────────────────
теоретический экзамен = теоретический экзамен
внутренний экзамен = внутренний экзамен
теория онлайн = теория онлайн
дистанционка = дистанционное обучение
очка = очное обучение
видео уроки = видеоуроки
видио уроки = видеоуроки
рубежка = рубежное тестирование
рубежи = рубежные тестирования
пдд = ПДД
пэдэдэ = ПДД
билеты пдд = билеты ПДД
автодром = автодром
площадка = автодром
город = город
змейка = змейка
эстакада = эстакада
параллельная парковка = параллельная парковка
разворот в ограниченном пространстве = разворот в ограниченном пространстве
# ── Люди и роли ─────────────────────────────────────────────────────────────
инструктор = инструктор
старший инструктор = старший инструктор
главный инспектор = старший инструктор
курсант = курсант
курсанты = курсанты
мастер производственного обучения = мастер производственного обучения
мпо = МПО
# ── Деньги и оплата ─────────────────────────────────────────────────────────
рассрочка = рассрочка
предоплата = предоплата
доплата = доплата
безнал = безналичный расчёт
безналичка = безналичный расчёт
нал = наличные
терминал = терминал
эквайринг = эквайринг
чек = чек
квитанция = квитанция
материнский капитал = материнский капитал
налоговый вычет = налоговый вычет
# ── Английские термины и сервисы ────────────────────────────────────────────
вотсап = WhatsApp
ватсап = WhatsApp
воцап = WhatsApp
телеграм = Telegram
телега = Telegram
вайбер = Viber
смс = СМС
онлайн = онлайн
оффлайн = офлайн
кэшбэк = кешбэк
кешбек = кешбэк
приложуха = приложение
личный кабинет = личный кабинет
црм = CRM
срм = CRM
кпи = KPI
# ── Продажи и переговоры ────────────────────────────────────────────────────
ледами = лидами
леды = лиды
лед = лид
кол-трекинг = коллтрекинг
колл трекинг = коллтрекинг
ретаргет = ретаргетинг
конверсионка = конверсия
заявка = заявка
запись на пробное = запись на пробное занятие
пробное занятие = пробное занятие
акция = акция
скидка = скидка
абонемент = абонемент
договор = договор
оферта = оферта
предоплату = предоплату
# Часто путаемые сокращения
црм = CRM
кпи = KPI
срм = CRM
# ── Частые ошибки распознавания на плохом звуке ─────────────────────────────
щас = сейчас
чё = что
чо = что
тыща = тысяча
тыщи = тысячи
тыщ = тысяч
пятсот = пятьсот
шестсот = шестьсот
восемсот = восемьсот
сюдой = сюда
тудой = туда
ага = ага
угу = угу
# Добавляйте свои строки ниже. Формат: что слышно = как надо
# ── Добавляйте свои строки ниже. Формат: что слышно = как надо ──────────────
+230 -9
View File
@@ -30,7 +30,8 @@ def client(tmp_path, monkeypatch):
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
monkeypatch.setattr(main.pipeline, "ffmpeg", "/bin/true", raising=False)
# ffmpeg ищется лениво, поэтому подменяем внутреннее поле, а не свойство
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
main._state["ready"] = True
main._worker_stop.set() # воркер не должен разбирать очередь в тестах
@@ -112,13 +113,233 @@ class TestHealth:
class TestSchemaExposure:
"""Схема API не требует токена, поэтому по умолчанию она закрыта."""
"""Штатный redoc остаётся выключенным: своя страница только одна."""
def test_docs_hidden_by_default(self, client):
assert client.get("/docs").status_code == 404
def test_openapi_hidden_by_default(self, client):
assert client.get("/openapi.json").status_code == 404
def test_redoc_hidden_by_default(self, client):
def test_redoc_is_off(self, client):
assert client.get("/redoc").status_code == 404
class TestHealthDiagnostics:
"""Без этих полей отказ по адресу отлаживается вслепую."""
def test_reports_client_ip(self, client):
body = client.get("/health").json()
assert "your_ip" in body
def test_reports_verdict_on_client_ip(self, client):
body = client.get("/health").json()
assert body["your_ip_allowed"] is True # в тестовом конфиге список пуст
def test_reports_whether_filter_is_active(self, client):
assert client.get("/health").json()["ip_filter_active"] is False
def test_reports_version(self, client):
from app.version import __version__
assert client.get("/health").json()["version"] == __version__
def test_does_not_leak_allowlist(self, client):
"""Список разрешённых адресов - не для посторонних глаз."""
body = client.get("/health").json()
assert "allow_ips" not in body and "allowlist" not in body
class TestConfigFileIsNotCode:
"""Настройки правятся в config.toml; app/config.py - код и обновляется."""
def test_update_archive_never_carries_user_config(self):
from app.updater import USER_FILES
assert "config.toml" in USER_FILES
def test_config_module_warns_it_is_not_settings(self):
from pathlib import Path
src = Path(__file__).resolve().parent.parent / "app" / "config.py"
text = src.read_text(encoding="utf-8")
assert "ЭТО КОД, А НЕ НАСТРОЙКИ" in text
class TestDocsPage:
"""Страница с методами полезна, но открывать её всем подряд незачем."""
@pytest.fixture(autouse=True)
def docs_on(self, client, monkeypatch):
"""По умолчанию документация выключена, здесь проверяется включённая.
Зависимость от client обязательна: та фикстура переимпортирует
app.main, и патч без неё лёг бы на выброшенный модуль.
"""
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
def test_docs_available_with_token(self, client):
assert client.get("/docs").status_code == 200
def test_openapi_available_with_token(self, client):
assert client.get("/openapi.json").status_code == 200
def test_docs_need_token(self, client):
"""Раньше страница открывалась без токена - с выключенным списком
адресов это означало открытый доступ из интернета."""
client.headers.pop("Authorization")
assert client.get("/docs").status_code == 401
def test_schema_declares_bearer_auth(self, client):
schema = client.get("/openapi.json").json()
assert "bearer" in schema["components"]["securitySchemes"]
def test_schema_lists_all_endpoints(self, client):
paths = client.get("/openapi.json").json()["paths"]
assert "/v1/jobs" in paths and "/health" in paths
@pytest.fixture
def restricted_client(tmp_path, monkeypatch):
"""Приложение со списком адресов, куда клиент тестов не входит."""
config = tmp_path / "config.toml"
config.write_text(
f'[server]\nhost="127.0.0.1"\nport=8756\n'
f'[security]\ntoken="{TOKEN}"\nallow_ips="10.1.2.3"\n'
f'[processing]\nthreads=1\n',
encoding="utf-8")
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
del sys.modules[mod]
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
monkeypatch.setattr(main.pipeline, "_ffmpeg", "/bin/true", raising=False)
main._state["ready"] = True
main._worker_stop.set()
with TestClient(main.app) as c:
yield c
class TestDocsAccessControl:
def test_docs_closed_for_foreign_ip(self, restricted_client, monkeypatch):
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
assert restricted_client.get("/docs").status_code == 403
def test_openapi_closed_for_foreign_ip(self, restricted_client, monkeypatch):
monkeypatch.setattr(sys.modules["app.main"].settings, "docs", True)
assert restricted_client.get("/openapi.json").status_code == 403
def test_docs_hidden_when_disabled(self, restricted_client):
"""Выключенная документация отвечает одинаково всем: её как бы нет."""
assert restricted_client.get("/docs").status_code == 404
def test_health_stays_open_for_foreign_ip(self, restricted_client):
"""Мониторинг должен работать всегда."""
assert restricted_client.get("/health").status_code == 200
class TestLogs:
def test_logs_require_token(self, client):
client.headers.pop("Authorization")
assert client.get("/v1/logs").status_code == 401
def test_logs_return_records(self, client):
import logging
from app.logbuffer import install
install()
logging.getLogger("talkscore-asr").error("тестовая запись")
body = client.get("/v1/logs").json()
assert any("тестовая запись" in r["message"] for r in body["records"])
def test_logs_filter_by_level(self, client):
import logging
from app.logbuffer import install
install()
log = logging.getLogger("talkscore-asr")
log.info("обычная строка")
log.error("строка об ошибке")
body = client.get("/v1/logs?level=ERROR").json()
assert all(r["level"] == "ERROR" for r in body["records"])
def test_buffer_keeps_only_recent(self):
from app.logbuffer import LogBuffer
buf = LogBuffer(capacity=10)
import logging
for i in range(50):
buf.emit(logging.LogRecord("t", logging.INFO, "f", 1, f"строка {i}", None, None))
rows = buf.tail(limit=100)
assert len(rows) == 10 and "строка 49" in rows[-1]["message"]
class TestBrokenConfig:
"""Со сломанным config.toml сервис должен объяснить причину, а не падать стеком."""
def test_duplicate_section_gives_readable_error(self, tmp_path):
from app.config import ConfigError, load_settings
config = tmp_path / "config.toml"
config.write_text('[security]\ntoken="a"\n[security]\ntrust_proxy="127.0.0.1"\n',
encoding="utf-8")
with pytest.raises(ConfigError, match="дважды"):
load_settings(config)
def test_error_names_the_file(self, tmp_path):
from app.config import ConfigError, load_settings
config = tmp_path / "config.toml"
config.write_text('[server]\n[server]\n', encoding="utf-8")
with pytest.raises(ConfigError, match="config.toml"):
load_settings(config)
def test_valid_config_still_loads(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[security]\ntoken="a"\ntrust_proxy="127.0.0.1"\n', encoding="utf-8")
assert load_settings(config).token == "a"
class TestDocsAccess:
"""Документация показывает устройство API, поэтому закрыта так же, как маршруты."""
@staticmethod
def module():
return sys.modules["app.main"]
@staticmethod
def anonymous(client, path):
"""Фикстура подставляет верный токен всем запросам - здесь он мешает."""
return client.get(path, headers={"Authorization": ""})
def test_disabled_docs_answer_404(self, client):
# Именно 404, а не 403: незачем подтверждать, что страница есть.
for path in ("/docs", "/openapi.json"):
assert client.get(path).status_code == 404
def test_enabled_docs_need_token(self, client, monkeypatch):
monkeypatch.setattr(self.module().settings, "docs", True)
for path in ("/docs", "/openapi.json"):
assert self.anonymous(client, path).status_code == 401
assert self.anonymous(client, f"{path}?token=chuzhoy").status_code == 401
def test_token_in_query_opens_docs(self, client, monkeypatch):
"""Браузер по ссылке заголовок не подставит, поэтому нужен ?token=."""
monkeypatch.setattr(self.module().settings, "docs", True)
for path in ("/docs", "/openapi.json"):
assert self.anonymous(client, f"{path}?token={TOKEN}").status_code == 200
def test_token_in_header_opens_docs(self, client, monkeypatch):
monkeypatch.setattr(self.module().settings, "docs", True)
assert client.get("/docs").status_code == 200
def test_schema_link_carries_token(self, client, monkeypatch):
"""Иначе страница откроется и тут же покажет ошибку доступа к схеме."""
monkeypatch.setattr(self.module().settings, "docs", True)
monkeypatch.setattr(self.module().settings, "token", "token s probelom i-slashem")
page = self.anonymous(client, "/docs?token=token s probelom i-slashem").text
assert "openapi.json?token=token%20s%20probelom%20i-slashem" in page
# Незакодированный пробел разорвал бы адрес, и схема не загрузилась бы.
assert "openapi.json?token=token s" not in page
+10 -5
View File
@@ -70,8 +70,13 @@ class TestDownloadScript:
class TestUpdateInStartup:
def test_start_bat_runs_updater_before_service(self, build_mod):
"""Обновление должно применяться до запуска, иначе смысла в нём нет."""
text = build_mod.START_BAT
assert "-m app.updater" in text
assert text.index("-m app.updater") < text.index("-m app.main")
def test_bat_only_delegates_to_launcher(self, build_mod):
"""Логика запуска живёт в app, иначе её не доставить обновлением.
Сам порядок «обновиться, потом запуститься» проверяется в
tests/test_launcher.py - здесь важно лишь, что .bat ничего не решает.
"""
for text in (build_mod.START_BAT, build_mod.HTTPS_BAT):
assert "-m app.launcher" in text
assert "-m app.main" not in text
assert "caddy" not in text.lower()
+364
View File
@@ -0,0 +1,364 @@
"""Тесты параллельной обработки.
Замеры показали: обе стадии упираются в 4 потока, а на 16 работают вчетверо
медленнее. Значит ядра нужно занимать не шириной одной задачи, а несколькими
задачами сразу - и тогда очередь обязана быть устойчивой к гонкам.
"""
import threading
import pytest
from app.store import JobStatus, JobStore
@pytest.fixture
def store(tmp_path):
return JobStore(tmp_path / "jobs.db")
class TestClaimIsAtomic:
def test_claim_marks_running(self, store):
job_id = store.create(filename="a.wav", duration_sec=1.0)
assert store.claim_next() == job_id
assert store.get(job_id)["status"] == JobStatus.RUNNING
def test_second_claim_gets_nothing(self, store):
store.create(filename="a.wav", duration_sec=1.0)
store.claim_next()
assert store.claim_next() is None
def test_each_job_claimed_once_under_load(self, store):
"""Главное требование: два воркера не должны взять одну задачу."""
ids = {store.create(filename=f"{i}.wav", duration_sec=1.0) for i in range(50)}
claimed: list[str] = []
lock = threading.Lock()
def worker():
while True:
job_id = store.claim_next()
if job_id is None:
return
with lock:
claimed.append(job_id)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(claimed) == len(set(claimed)) == 50
assert set(claimed) == ids
def test_claims_oldest_first(self, store):
first = store.create(filename="1.wav", duration_sec=1.0)
store.create(filename="2.wav", duration_sec=1.0)
assert store.claim_next() == first
class TestWorkerSettings:
def test_default_threads_is_capped(self, tmp_path, monkeypatch):
"""Дефолт умеренный: оптимум зависит от процессора и подбирается замером."""
from app.config import load_settings
monkeypatch.setattr("os.cpu_count", lambda: 32)
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=0\n', encoding="utf-8")
assert load_settings(config).effective_threads() == 8
def test_explicit_threads_respected(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=6\n', encoding="utf-8")
assert load_settings(config).effective_threads() == 6
def test_workers_derived_from_cores(self, tmp_path, monkeypatch):
from app.config import load_settings
monkeypatch.setattr("os.cpu_count", lambda: 32)
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
assert 1 <= load_settings(config).effective_workers() <= 4
def test_workers_never_below_one(self, tmp_path, monkeypatch):
from app.config import load_settings
monkeypatch.setattr("os.cpu_count", lambda: 1)
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=4\nworkers=0\n', encoding="utf-8")
assert load_settings(config).effective_workers() >= 1
def test_explicit_workers_respected(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nworkers=3\n', encoding="utf-8")
assert load_settings(config).effective_workers() == 3
class TestLazyWarmup:
"""Воркеры создаются заранее, а модели грузят при первой своей задаче."""
def test_transcribe_warms_up_when_needed(self, tmp_path, monkeypatch):
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
from app.pipeline import Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
called = {"warmup": 0}
monkeypatch.setattr(p, "warmup", lambda: called.__setitem__("warmup", 1))
# transcribe упадёт дальше на чтении файла, но warmup обязан быть вызван
with pytest.raises(Exception):
p.transcribe(tmp_path / "нет.wav")
assert called["warmup"] == 1
def test_no_runtime_error_about_warmup(self, tmp_path, monkeypatch):
"""Прежде здесь падало «модели не загружены, вызовите warmup()»."""
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
from app.pipeline import Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
monkeypatch.setattr(p, "warmup", lambda: None)
try:
p.transcribe(tmp_path / "нет.wav")
except RuntimeError as exc:
assert "warmup" not in str(exc)
except Exception:
pass
class TestBenchmarkEndpoint:
def test_benchmark_requires_token(self, tmp_path, monkeypatch):
import sys
import types
from fastapi.testclient import TestClient
config = tmp_path / "config.toml"
config.write_text('[security]\ntoken="t"\nallow_ips=""\n', encoding="utf-8")
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
del sys.modules[mod]
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
main._worker_stop.set()
with TestClient(main.app) as c:
assert c.post("/v1/benchmark", files={"file": ("a.wav", b"x")}).status_code == 401
class TestSeparationQuality:
"""Метрика нужна, чтобы было видно, когда разметке по голосам верить нельзя."""
def test_distinct_voices_score_high(self):
import numpy as np
from app.acoustics import separation_quality
emb = np.vstack([np.random.RandomState(0).randn(8, 64),
np.random.RandomState(1).randn(8, 64) + 6])
labels = np.array([0] * 8 + [1] * 8)
assert separation_quality(emb, labels) > 0.4
def test_indistinguishable_voices_score_low(self):
import numpy as np
from app.acoustics import separation_quality
emb = np.random.RandomState(2).randn(16, 64)
labels = np.array([0] * 8 + [1] * 8)
assert separation_quality(emb, labels) < 0.3
def test_too_few_segments_returns_zero(self):
import numpy as np
from app.acoustics import separation_quality
assert separation_quality(np.random.randn(2, 64), np.array([0, 1])) == 0.0
def test_acoustics_reflect_high_frequency_content(self):
import numpy as np
from app.acoustics import segment_acoustics
sr = 16000
t = np.arange(sr) / sr
low = np.sin(2 * np.pi * 300 * t).astype(np.float32)
high = np.sin(2 * np.pi * 5000 * t).astype(np.float32)
assert segment_acoustics(high)["hf_ratio"] > segment_acoustics(low)["hf_ratio"]
def test_acoustics_reflect_loudness(self):
import numpy as np
from app.acoustics import segment_acoustics
loud = (np.random.RandomState(0).randn(16000) * 0.3).astype(np.float32)
quiet = loud * 0.1
assert segment_acoustics(loud)["loudness_db"] > segment_acoustics(quiet)["loudness_db"]
class TestProcessPool:
"""Расчёты уходят в процессы: библиотеки держат GIL, в потоках выигрыша нет."""
def test_worker_module_does_not_import_main(self):
"""На Windows дочерний процесс заново импортирует модуль с функцией.
Если бы он тянул app.main, в каждом процессе поднимался бы ещё один
веб-сервер со своей очередью. Проверяем сами импорты, а не текст файла.
"""
import ast
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "app" / "worker.py").read_text(encoding="utf-8")
imported = set()
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.Import):
imported.update(a.name for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module)
assert "app.main" not in imported
def test_pool_failure_falls_back_to_single_process(self, monkeypatch):
"""Сбой процессов не должен ронять сервис."""
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
del sys.modules[mod]
import app.main as main
monkeypatch.setattr("concurrent.futures.ProcessPoolExecutor",
lambda **kw: (_ for _ in ()).throw(OSError("нет процессов")))
assert main._make_pool(4) is None
def test_run_job_needs_initialised_worker(self):
import app.worker as w
w._pipeline = None
try:
w.run_job("нет.wav", 2, "ffmpeg")
except RuntimeError as exc:
assert "не инициализирован" in str(exc)
else:
raise AssertionError("ожидалась ошибка о неинициализированном воркере")
class TestSpawnSafety:
"""На Windows дочерний процесс заново импортирует app.main."""
def test_importing_main_does_not_open_store(self, tmp_path, monkeypatch):
"""Иначе каждый новый процесс объявлял бы чужие задачи сорванными."""
import sys
import types
config = tmp_path / "config.toml"
config.write_text('[security]\ntoken="t"\n', encoding="utf-8")
monkeypatch.setenv("TALKSCORE_ASR_HOME", str(tmp_path))
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
for mod in [m for m in list(sys.modules) if m.startswith("app.")]:
del sys.modules[mod]
import app.config as cfg
monkeypatch.setattr(cfg, "BASE_DIR", tmp_path)
import app.main as main
assert main.store is None
assert not (tmp_path / "data" / "jobs.db").exists()
class TestSeparationNeverBreaksJob:
"""Оценка разделения вспомогательная и не должна ронять расшифровку."""
def test_embedding_uses_short_piece(self):
"""Длинный фрагмент модель отпечатков не переваривает."""
from app.pipeline import EMBED_SEC
assert EMBED_SEC <= 10
def test_failure_in_quality_does_not_break_transcribe(self, tmp_path, monkeypatch):
import sys
import types
import numpy as np
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
from app.pipeline import Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
p._asr = types.SimpleNamespace(recognize=lambda *a, **k: "текст")
monkeypatch.setattr(p, "warmup", lambda: None)
monkeypatch.setattr(p, "_reload_replacements", lambda: None)
monkeypatch.setattr(p, "_diarizer", lambda n: types.SimpleNamespace(
process=lambda s: types.SimpleNamespace(
sort_by_start_time=lambda: [types.SimpleNamespace(start=0.0, end=2.0, speaker=0)])))
monkeypatch.setattr(p, "_separation_quality",
lambda *a: (_ for _ in ()).throw(RuntimeError("модель упала")))
monkeypatch.setattr("app.pipeline.read_wav", lambda _: np.zeros(16000 * 3, dtype=np.float32))
result = p.transcribe(tmp_path / "any.wav")
assert result["stats"]["separation_quality"] == 0.0
assert result["turns"] # расшифровка на месте
class TestNormalization:
"""Выравнивание громкости решает исход диаризации на записях с одним микрофоном."""
def test_filter_applied_by_default(self, tmp_path, monkeypatch):
import subprocess
from app.pipeline import NORMALIZE_FILTER, to_wav16k
captured = {}
monkeypatch.setattr(subprocess, "run",
lambda cmd, **kw: captured.setdefault("cmd", cmd))
monkeypatch.setattr("wave.open", lambda *a, **k: type(
"W", (), {"getnframes": lambda s: 16000, "getframerate": lambda s: 16000,
"__enter__": lambda s: s, "__exit__": lambda *a: None})())
to_wav16k(tmp_path / "in.mp3", tmp_path / "out.wav", "ffmpeg")
assert NORMALIZE_FILTER in captured["cmd"]
def test_can_be_disabled(self, tmp_path, monkeypatch):
import subprocess
from app.pipeline import NORMALIZE_FILTER, to_wav16k
captured = {}
monkeypatch.setattr(subprocess, "run",
lambda cmd, **kw: captured.setdefault("cmd", cmd))
monkeypatch.setattr("wave.open", lambda *a, **k: type(
"W", (), {"getnframes": lambda s: 16000, "getframerate": lambda s: 16000,
"__enter__": lambda s: s, "__exit__": lambda *a: None})())
to_wav16k(tmp_path / "in.mp3", tmp_path / "out.wav", "ffmpeg", normalize=False)
assert NORMALIZE_FILTER not in captured["cmd"]
def test_settings_default_to_normalizing(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text('[processing]\nthreads=4\n', encoding="utf-8")
assert load_settings(config).normalize is True
def test_parameters_are_the_measured_ones(self):
"""f=400:g=3 давал 8.6 процента вместо 43.2 - параметры менять нельзя наугад."""
from app.pipeline import NORMALIZE_FILTER
assert "f=200" in NORMALIZE_FILTER and "g=5" in NORMALIZE_FILTER
+113
View File
@@ -0,0 +1,113 @@
"""Проверки читаемости консольного вывода."""
import logging
import sys
from unittest.mock import patch
from app.console import ConsoleFormatter, enable_ansi, setup_logging
def record(level: int, message: str) -> logging.LogRecord:
return logging.LogRecord("talkscore-asr", level, __file__, 1, message, None, None)
class TestWrapping:
def test_short_message_stays_on_one_line(self):
out = ConsoleFormatter(colored=False).format(record(logging.INFO, "готово"))
assert out.count("\n") == 0
assert out.endswith("готово")
def test_long_message_wraps(self):
out = ConsoleFormatter(colored=False, width=lambda: 80).format(
record(logging.INFO, "слово " * 60))
assert out.count("\n") >= 1
assert all(len(line) <= 80 for line in out.splitlines())
def test_continuation_aligned_under_message(self):
"""Перенос идёт под текст, а не под начало строки - так видно границу сообщений."""
out = ConsoleFormatter(colored=False, width=lambda: 70).format(
record(logging.WARNING, "слово " * 40))
first, second = out.splitlines()[:2]
head = len(first) - len(first.lstrip()) + first.index("слово")
assert second.startswith(" " * head)
assert second.lstrip()[0] != " "
def test_existing_newlines_are_kept(self):
out = ConsoleFormatter(colored=False).format(
record(logging.INFO, "первая\nвторая"))
assert "первая" in out.splitlines()[0]
assert "вторая" in out.splitlines()[1]
class TestColour:
def test_no_escape_codes_when_colour_off(self):
"""Главное требование: без цвета в выводе не должно быть ANSI-мусора."""
for level in (logging.INFO, logging.WARNING, logging.ERROR):
out = ConsoleFormatter(colored=False).format(record(level, "текст"))
assert "\033" not in out
def test_warning_and_error_are_painted(self):
for level in (logging.WARNING, logging.ERROR):
out = ConsoleFormatter(colored=True).format(record(level, "текст"))
assert "\033[" in out
def test_info_level_is_not_painted(self):
"""Обычные сообщения остаются неокрашенными, иначе цвет перестаёт что-то значить."""
out = ConsoleFormatter(colored=True).format(record(logging.INFO, "текст"))
assert "\033[3" not in out # ни жёлтого, ни красного
class TestAnsiDetection:
def test_disabled_when_output_is_redirected(self):
"""Перенаправленный в файл вывод красить нельзя - в файл попадут коды."""
with patch.object(sys.stdout, "isatty", return_value=False):
assert enable_ansi() is False
class TestSetup:
def test_setup_replaces_handlers(self):
root = logging.getLogger()
before = root.handlers[:]
try:
setup_logging(logging.INFO)
assert len(root.handlers) == 1
assert isinstance(root.handlers[0].formatter, ConsoleFormatter)
finally:
root.handlers[:] = before
class TestPortCheck:
"""Занятый порт должен диагностироваться до запуска, а не строкой Errno 10048."""
def test_free_port_is_not_busy(self):
import socket as sk
from app.main import _port_busy
with sk.socket() as probe:
probe.bind(("127.0.0.1", 0))
free = probe.getsockname()[1]
# Сокет закрыт, порт снова свободен.
assert _port_busy("127.0.0.1", free) is False
def test_listening_port_is_busy(self):
import socket as sk
from app.main import _port_busy
with sk.socket() as server:
server.bind(("127.0.0.1", 0))
server.listen(1)
port = server.getsockname()[1]
assert _port_busy("127.0.0.1", port) is True
def test_wildcard_host_is_probed_via_loopback(self):
"""При host=0.0.0.0 подключаться к 0.0.0.0 нельзя, проверяем петлю."""
import socket as sk
from app.main import _port_busy
with sk.socket() as server:
server.bind(("127.0.0.1", 0))
server.listen(1)
port = server.getsockname()[1]
assert _port_busy("0.0.0.0", port) is True
+112
View File
@@ -0,0 +1,112 @@
"""Проверки генерации Caddyfile и запуска."""
from app.launcher import MARK, ensure_caddyfile, render_caddyfile
class TestRender:
def test_domains_go_into_site_block(self):
text = render_caddyfile("a.example.com, b.example.com", 8756, 500)
assert "a.example.com, b.example.com {" in text
def test_extra_spaces_and_empty_entries_are_dropped(self):
text = render_caddyfile(" a.example.com ,, b.example.com ,", 8756, 500)
assert "a.example.com, b.example.com {" in text
def test_port_and_limit_come_from_settings(self):
text = render_caddyfile("a.example.com", 9000, 250)
assert "reverse_proxy 127.0.0.1:9000" in text
assert "max_size 250MB" in text
def test_xff_is_left_to_caddy_default(self):
"""Caddy сам игнорирует X-Forwarded-For от клиента и ставит реальный адрес.
Свой header_up был лишним - Caddy предупреждал об этом при каждом
запуске. Важнее обратное: trusted_proxies появляться не должен, иначе
Caddy начнёт верить заголовку клиента и адрес станет подделываемым.
"""
# Комментарии не в счёт: в них эти слова упомянуты как предостережение.
directives = "\n".join(
line for line in render_caddyfile("a.example.com", 8756, 500).splitlines()
if not line.strip().startswith("#"))
assert "header_up" not in directives
assert "trusted_proxies" not in directives
def test_caddy_log_goes_to_file(self):
"""Иначе Caddy забивает консоль сообщениями про сертификаты."""
text = render_caddyfile("a.example.com", 8756, 500)
assert "output file logs/caddy.log" in text
def test_generated_file_is_marked(self):
assert render_caddyfile("a.example.com", 8756, 500).startswith(MARK)
def test_no_blank_line_before_global_block(self):
"""caddy fmt считает такой файл неформатированным и ругается при запуске."""
lines = render_caddyfile("a.example.com", 8756, 500).splitlines()
assert lines[lines.index("{") - 1].strip() != ""
class TestEnsure:
def test_creates_file_when_missing(self, tmp_path):
content = render_caddyfile("a.example.com", 8756, 500)
written = ensure_caddyfile(tmp_path, content)
assert written == tmp_path / "Caddyfile"
assert written.read_text(encoding="utf-8") == content
def test_overwrites_own_previous_version(self, tmp_path):
ensure_caddyfile(tmp_path, render_caddyfile("old.example.com", 8756, 500))
fresh = render_caddyfile("new.example.com", 8756, 500)
ensure_caddyfile(tmp_path, fresh)
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == fresh
assert not (tmp_path / "Caddyfile.new").exists()
def test_keeps_hand_written_file(self, tmp_path):
"""Чужой файл не трогаем: молча стереть настройку хуже, чем оставить старую."""
handmade = "site.example.com {\n\treverse_proxy 127.0.0.1:1234\n}\n"
(tmp_path / "Caddyfile").write_text(handmade, encoding="utf-8")
written = ensure_caddyfile(tmp_path, render_caddyfile("a.example.com", 8756, 500))
assert (tmp_path / "Caddyfile").read_text(encoding="utf-8") == handmade
assert written == tmp_path / "Caddyfile.new"
assert MARK in written.read_text(encoding="utf-8")
def test_no_rewrite_when_nothing_changed(self, tmp_path):
content = render_caddyfile("a.example.com", 8756, 500)
ensure_caddyfile(tmp_path, content)
before = (tmp_path / "Caddyfile").stat().st_mtime_ns
assert ensure_caddyfile(tmp_path, content) is None
assert (tmp_path / "Caddyfile").stat().st_mtime_ns == before
class TestStartupOrder:
def test_updater_runs_before_service(self, tmp_path, monkeypatch):
"""Обновиться нужно до запуска, иначе обновление бессмысленно."""
import app.launcher as launcher
calls: list[str] = []
class FakeSettings:
base_dir = tmp_path
port = 8756
max_upload_mb = 500
https_domains = ""
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
monkeypatch.setattr("app.updater.main", lambda: calls.append("updater"))
monkeypatch.setitem(
__import__("sys").modules, "app.main",
type("M", (), {"run": staticmethod(lambda: calls.append("run"))}))
assert launcher.main([]) == 0
assert calls == ["updater", "run"]
def test_https_without_domains_stops_with_explanation(self, tmp_path, monkeypatch):
"""Пустые домены - это ошибка настройки, а не повод падать внутри Caddy."""
import app.launcher as launcher
class FakeSettings:
base_dir = tmp_path
port = 8756
max_upload_mb = 500
https_domains = " "
monkeypatch.setattr("app.config.load_settings", lambda: FakeSettings())
monkeypatch.setattr("app.updater.main", lambda: None)
assert launcher.main(["https"]) == 1
+44
View File
@@ -0,0 +1,44 @@
"""Тесты определения адреса клиента за обратным прокси.
Caddy держит HTTPS и проксирует запросы на localhost. Без учёта
X-Forwarded-For список разрешённых адресов увидит вместо клиента localhost
и пустит кого угодно. Но доверять заголовку можно только от самого прокси -
иначе его подделает любой желающий.
"""
import pytest
from app.security import client_address, parse_allowlist
class TestClientAddress:
def test_direct_connection_uses_peer(self):
assert client_address("203.0.113.5", None, parse_allowlist("")) == "203.0.113.5"
def test_forwarded_header_used_from_trusted_proxy(self):
trusted = parse_allowlist("127.0.0.1")
assert client_address("127.0.0.1", "203.0.113.5", trusted) == "203.0.113.5"
def test_forwarded_header_ignored_from_untrusted_peer(self):
"""Подделка заголовка не должна давать доступ."""
trusted = parse_allowlist("127.0.0.1")
assert client_address("198.51.100.9", "10.0.0.1", trusted) == "198.51.100.9"
def test_takes_first_address_from_chain(self):
trusted = parse_allowlist("127.0.0.1")
assert client_address("127.0.0.1", "203.0.113.5, 70.41.3.18", trusted) == "203.0.113.5"
def test_empty_header_falls_back_to_peer(self):
trusted = parse_allowlist("127.0.0.1")
assert client_address("127.0.0.1", "", trusted) == "127.0.0.1"
def test_no_trusted_proxies_ignores_header(self):
"""Пока прокси не настроен, заголовку верить нельзя вообще."""
assert client_address("127.0.0.1", "203.0.113.5", parse_allowlist("")) == "127.0.0.1"
def test_malformed_header_falls_back_to_peer(self):
trusted = parse_allowlist("127.0.0.1")
assert client_address("127.0.0.1", "не-адрес", trusted) == "127.0.0.1"
def test_ipv6_proxy_supported(self):
trusted = parse_allowlist("::1")
assert client_address("::1", "203.0.113.5", trusted) == "203.0.113.5"
+15 -1
View File
@@ -1,7 +1,7 @@
"""Тесты доступа: токен и список разрешённых адресов."""
import pytest
from app.security import check_token, ip_allowed, parse_allowlist
from app.security import token_matches, check_token, ip_allowed, parse_allowlist
class TestToken:
@@ -62,3 +62,17 @@ class TestAllowlist:
def test_unknown_client_ip_denied_when_list_set(self):
nets = parse_allowlist("10.0.0.1")
assert ip_allowed(None, nets) is False
class TestNonAsciiToken:
"""compare_digest на строках с не-ASCII бросает TypeError: был бы 500 вместо 401."""
def test_cyrillic_token_matches_itself(self):
assert token_matches("секрет-ключ", "секрет-ключ") is True
def test_cyrillic_token_rejects_other(self):
assert token_matches("другой", "секрет-ключ") is False
def test_cyrillic_token_via_header(self):
assert check_token("Bearer секрет-ключ", "секрет-ключ") is True
assert check_token("Bearer чужой", "секрет-ключ") is False
+6 -6
View File
@@ -51,18 +51,18 @@ class TestJobLifecycle:
class TestQueue:
def test_take_next_returns_oldest_queued(self, store):
def test_claim_returns_oldest_queued(self, store):
first = store.create(filename="1.wav", duration_sec=1.0)
store.create(filename="2.wav", duration_sec=1.0)
assert store.take_next() == first
assert store.claim_next() == first
def test_take_next_skips_running(self, store):
def test_claim_skips_running(self, store):
job_id = store.create(filename="1.wav", duration_sec=1.0)
store.mark_running(job_id)
assert store.take_next() is None
assert store.claim_next() is None
def test_take_next_on_empty_queue(self, store):
assert store.take_next() is None
def test_claim_on_empty_queue(self, store):
assert store.claim_next() is None
def test_queue_position_counts_only_waiting(self, store):
a = store.create(filename="1.wav", duration_sec=1.0)
+48 -1
View File
@@ -1,7 +1,7 @@
"""Тесты постобработки текста: словарь замен и типографика."""
import pytest
from app.text import apply_replacements, load_replacements, normalize_typography
from app.text import apply_replacements, load_replacements, normalize_typography, split_sentences
class TestReplacements:
@@ -74,3 +74,50 @@ class TestTypography:
def test_keeps_hyphen_in_compound_word(self):
assert normalize_typography("look-alike и из-за") == "look-alike и из-за"
class TestSplitSentences:
"""Разметку ролей делает LLM по предложениям: реплику целиком разметить нельзя."""
@staticmethod
def turn(text, start=0.0, end=10.0, loudness=-20.0):
return {"text": text, "start": start, "end": end,
"acoustics": {"loudness_db": loudness}}
def test_splits_on_sentence_endings(self):
out = split_sentences([self.turn("Первое. Второе! Третье? Четвёртое…")])
assert [s["text"] for s in out] == ["Первое.", "Второе!", "Третье?", "Четвёртое…"]
def test_numbering_is_continuous_across_turns(self):
out = split_sentences([self.turn("А. Б."), self.turn("В. Г.")])
assert [s["n"] for s in out] == [1, 2, 3, 4]
def test_turn_index_is_kept(self):
"""По нему собирают диалог обратно после разметки."""
out = split_sentences([self.turn("А. Б."), self.turn("В.")])
assert [s["turn"] for s in out] == [0, 0, 1]
def test_time_is_spread_inside_turn(self):
out = split_sentences([self.turn("А. Б. В.", start=0.0, end=30.0)])
assert [s["start"] for s in out] == [0.0, 10.0, 20.0]
def test_loudness_comes_from_turn(self):
out = split_sentences([self.turn("А. Б.", loudness=-17.5)])
assert all(s["loudness_db"] == -17.5 for s in out)
def test_turn_without_acoustics_gives_none(self):
"""Короткие фрагменты акустику не считают - поле должно быть пустым, не падать."""
out = split_sentences([{"text": "А.", "start": 0.0, "end": 1.0, "acoustics": {}}])
assert out[0]["loudness_db"] is None
def test_text_without_punctuation_stays_one_sentence(self):
out = split_sentences([self.turn("совсем без знаков препинания")])
assert len(out) == 1
def test_empty_text_is_skipped(self):
assert split_sentences([self.turn(" ")]) == []
def test_abbreviation_dots_do_not_split(self):
"""Точка без пробела после неё концом предложения не считается."""
out = split_sentences([self.turn("Нужен документ п.2.1 из списка.")])
assert len(out) == 1
+14
View File
@@ -71,3 +71,17 @@ class TestSpeechStats:
stats = speech_stats([])
assert stats["speakers"] == 0
assert stats["speech_sec"] == 0
class TestChunkLimit:
def test_default_chunk_is_conservative(self):
"""Пределы у разных сборок onnxruntime разные, берём с запасом."""
from app.pipeline import MAX_CHUNK_SEC
assert MAX_CHUNK_SEC <= 60
def test_long_turn_split_below_limit(self):
from app.pipeline import MAX_CHUNK_SEC
for a, b in chunk_ranges(0.0, 600.0, max_len=MAX_CHUNK_SEC):
assert b - a <= MAX_CHUNK_SEC
+67
View File
@@ -126,3 +126,70 @@ class TestInstall:
z.writestr("app/main.py", "код")
with pytest.raises(UpdateError, match="путь"):
install_update(buf.getvalue(), base, verify=lambda _: True)
class TestPipelineImportSafety:
def test_pipeline_constructs_without_ffmpeg(self, tmp_path, monkeypatch):
"""Отсутствие ffmpeg не должно ронять импорт: иначе непонятно, что чинить."""
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.setattr("shutil.which", lambda _: None)
from app.pipeline import Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
assert p is not None # конструктор не упал
def test_ffmpeg_lookup_fails_only_when_used(self, tmp_path, monkeypatch):
import sys
import types
for name in ("sherpa_onnx", "onnx_asr", "onnxruntime"):
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.setattr("shutil.which", lambda _: None)
from app.pipeline import ModelsMissing, Pipeline
p = Pipeline(models_dir=tmp_path, threads=1,
replacements_path=tmp_path / "r.txt", base_dir=tmp_path)
with pytest.raises(ModelsMissing, match="ffmpeg"):
_ = p.ffmpeg
class TestApiDiagnostics:
"""404 на приватном репозитории Gitea означает проблему с токеном, не отсутствие релиза."""
class FakeResponse:
def __init__(self, status):
self.status_code = status
def raise_for_status(self):
raise AssertionError("до сюда доходить не должно")
class FakeSession:
def __init__(self, status):
self.status = status
def get(self, url, headers=None, timeout=None):
return TestApiDiagnostics.FakeResponse(self.status)
@pytest.mark.parametrize("status", [401, 403, 404])
def test_auth_errors_mention_token(self, status):
# Импорт внутри теста: другие тесты перезагружают app.*, и класс
# исключения, взятый на уровне модуля, перестал бы совпадать.
from app.updater import UpdateError as Err
from app.updater import _api
with pytest.raises(Err, match="токен"):
_api(self.FakeSession(status), "http://x", token="какой-то")
def test_missing_token_says_where_to_put_it(self):
from app.updater import UpdateError as Err
from app.updater import _api
with pytest.raises(Err, match="config.toml"):
_api(self.FakeSession(404), "http://x", token="")
+125
View File
@@ -0,0 +1,125 @@
"""Тесты установки обновления.
Первая версия перемещала папку app целиком - и на Windows это сломало сервис:
код выполнялся из этой самой папки, перемещение не прошло, откат тоже не смог
её вернуть, и app просто исчезла. Отсюда требования ниже: не двигать и не
удалять рабочую папку, проверять новый код до установки, уметь откатиться
пофайлово.
"""
import io
import zipfile
import pytest
from app.updater import UpdateError, install_update
def make_zip(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, text in files.items():
z.writestr(name, text)
return buf.getvalue()
@pytest.fixture
def base(tmp_path):
app = tmp_path / "app"
app.mkdir()
(app / "main.py").write_text("старый main", encoding="utf-8")
(app / "version.py").write_text('__version__ = "0.1.0"', encoding="utf-8")
return tmp_path
class TestNoDestructiveMoves:
def test_app_directory_is_never_removed(self, base, monkeypatch):
"""rmtree по рабочей папке - прямой путь к потере сервиса."""
import shutil
removed = []
monkeypatch.setattr(shutil, "rmtree",
lambda p, **kw: removed.append(str(p)))
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
assert not any(str(p).endswith("app") for p in removed)
def test_app_directory_is_never_moved(self, base, monkeypatch):
import shutil
moved = []
real_move = shutil.move
monkeypatch.setattr(shutil, "move",
lambda s, d, **kw: (moved.append(str(s)), real_move(s, d))[1])
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
assert not any(str(p).rstrip("/\\").endswith("app") for p in moved)
def test_directory_keeps_same_inode(self, base):
"""Папка должна остаться той же самой, а не быть пересозданной."""
before = (base / "app").stat().st_ino
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
assert (base / "app").stat().st_ino == before
class TestVerifyBeforeInstall:
def test_broken_version_never_touches_working_files(self, base):
"""Проверять надо до установки, иначе сломанный код уже стоит."""
with pytest.raises(UpdateError):
install_update(make_zip({"app/main.py": "сломанный"}), base,
verify=lambda _: False)
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main"
def test_verify_receives_staging_not_live_dir(self, base):
seen = {}
def verify(path):
seen["path"] = path
# На момент проверки рабочий файл ещё не тронут
seen["live_intact"] = (base / "app" / "main.py").read_text(encoding="utf-8")
return True
install_update(make_zip({"app/main.py": "новый"}), base, verify=verify)
assert seen["live_intact"] == "старый main"
assert seen["path"] != base
class TestInstallResult:
def test_files_are_updated(self, base):
install_update(make_zip({"app/main.py": "новый",
"app/version.py": '__version__ = "0.2.0"'}),
base, verify=lambda _: True)
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый"
assert '0.2.0' in (base / "app" / "version.py").read_text(encoding="utf-8")
def test_new_files_appear(self, base):
install_update(make_zip({"app/main.py": "новый", "app/extra.py": "доп"}),
base, verify=lambda _: True)
assert (base / "app" / "extra.py").is_file()
def test_leftover_files_from_old_version_are_removed(self, base):
"""Файл, которого больше нет в релизе, не должен оставаться и импортироваться."""
(base / "app" / "obsolete.py").write_text("устаревший", encoding="utf-8")
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
assert not (base / "app" / "obsolete.py").exists()
def test_rollback_restores_files_on_copy_failure(self, base, monkeypatch):
"""Если копирование упало на середине, возвращаем прежние файлы."""
import shutil
calls = {"n": 0}
real_copy = shutil.copy2
def flaky_copy(src, dst, **kw):
calls["n"] += 1
if calls["n"] == 2:
raise OSError("файл занят")
return real_copy(src, dst)
monkeypatch.setattr(shutil, "copy2", flaky_copy)
with pytest.raises(OSError):
install_update(make_zip({"app/main.py": "новый", "app/a.py": "a",
"app/b.py": "b"}), base, verify=lambda _: True)
assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main"
def test_no_temp_dirs_left_behind(self, base):
install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True)
leftovers = [p.name for p in base.iterdir() if p.name.startswith(".update")]
assert leftovers == []
+97
View File
@@ -0,0 +1,97 @@
"""Тесты доставки результата на сторонний адрес."""
import hashlib
import hmac
import json
import pytest
from app.webhook import deliver, sign_payload
class FakeResponse:
def __init__(self, status):
self.status_code = status
class TestSignature:
def test_signature_matches_hmac(self):
body = b'{"job_id":"x"}'
expected = hmac.new(b"secret", body, hashlib.sha256).hexdigest()
assert sign_payload(body, "secret") == expected
def test_different_secret_gives_different_signature(self):
body = b'{"job_id":"x"}'
assert sign_payload(body, "a") != sign_payload(body, "b")
def test_signature_changes_with_body(self):
assert sign_payload(b"one", "s") != sign_payload(b"two", "s")
class TestDelivery:
def test_successful_delivery_sends_once(self, monkeypatch):
calls = []
def fake_post(url, data=None, headers=None, timeout=None):
calls.append((url, data, headers))
return FakeResponse(200)
monkeypatch.setattr("requests.post", fake_post)
assert deliver("http://x/hook", {"job_id": "1"}, "s", delays=(0,)) is True
assert len(calls) == 1
def test_signature_header_present(self, monkeypatch):
seen = {}
def fake_post(url, data=None, headers=None, timeout=None):
seen.update(headers)
return FakeResponse(200)
monkeypatch.setattr("requests.post", fake_post)
deliver("http://x", {"job_id": "1"}, "секрет", delays=(0,))
assert "X-Talkscore-Signature" in seen
def test_no_signature_without_secret(self, monkeypatch):
seen = {}
monkeypatch.setattr("requests.post",
lambda url, data=None, headers=None, timeout=None:
(seen.update(headers), FakeResponse(200))[1])
deliver("http://x", {"job_id": "1"}, "", delays=(0,))
assert "X-Talkscore-Signature" not in seen
def test_retries_on_server_error(self, monkeypatch):
attempts = []
monkeypatch.setattr("requests.post",
lambda url, data=None, headers=None, timeout=None:
(attempts.append(1), FakeResponse(500))[1])
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0, 0)) is False
assert len(attempts) == 3
def test_retries_on_network_failure(self, monkeypatch):
attempts = []
def boom(url, data=None, headers=None, timeout=None):
attempts.append(1)
raise OSError("сеть недоступна")
monkeypatch.setattr("requests.post", boom)
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0)) is False
assert len(attempts) == 2
def test_stops_after_first_success(self, monkeypatch):
attempts = []
def flaky(url, data=None, headers=None, timeout=None):
attempts.append(1)
return FakeResponse(500 if len(attempts) == 1 else 200)
monkeypatch.setattr("requests.post", flaky)
assert deliver("http://x", {"job_id": "1"}, "", delays=(0, 0, 0)) is True
assert len(attempts) == 2
def test_payload_is_valid_json_utf8(self, monkeypatch):
seen = {}
monkeypatch.setattr("requests.post",
lambda url, data=None, headers=None, timeout=None:
(seen.update({"body": data}), FakeResponse(200))[1])
deliver("http://x", {"text": "русский текст"}, "", delays=(0,))
assert json.loads(seen["body"].decode("utf-8"))["text"] == "русский текст"