Make scripts better
This commit is contained in:
parent
ba56147e95
commit
e8ad7df469
12 changed files with 614 additions and 432 deletions
|
|
@ -1,17 +0,0 @@
|
|||
# QUICKSTART — meeting-report
|
||||
|
||||
## 1. Зависимости
|
||||
apt-get update && apt-get install -y ffmpeg
|
||||
pip install faster-whisper weasyprint
|
||||
|
||||
## 2. Если故意 запускаешь long транскрипцию
|
||||
export MKL_SERVICE_FORCE_INTEL=1
|
||||
export OMP_NUM_THREADS=2
|
||||
|
||||
bash scripts/transcribe_chunked.sh /app/hermes_data/meetings/2026-03-18
|
||||
|
||||
## 3. Генерация PDF
|
||||
python3 scripts/generate_pdf.py report.md report.pdf
|
||||
|
||||
## ПАМЯТКА
|
||||
Всегда используй MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 перед запуском Python + faster-whisper!
|
||||
72
README.md
72
README.md
|
|
@ -1,72 +0,0 @@
|
|||
# meeting-report — генерация отчётов по аудиозаписям встреч
|
||||
|
||||
## Функционал
|
||||
- Транскрипция WAV аудио через faster-whisper
|
||||
- Генерация аналитического отчёта в Markdown
|
||||
- Экспорт в PDF (через pandoc или weasyprint)
|
||||
|
||||
## Установка зависимостей (Docker/Debian-based)
|
||||
apt-get update && apt-get install -y ffmpeg pandoc texlive-xetex texlive-lang-cyrillic fonts-noto
|
||||
pip install faster-whisper weasyprint
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Шаг 0. Подготовка аудио
|
||||
Положите WAV файл(ы) в папку встречи:
|
||||
/app/hermes_data/meetings/YYYY-MM-DD/
|
||||
|
||||
### Шаг 1. Установка переменных окружения (КРИТИЧНО!)
|
||||
Перед запуском транскрипции НУЖНО установить:
|
||||
export MKL_SERVICE_FORCE_INTEL=1
|
||||
export OMP_NUM_THREADS=2
|
||||
|
||||
Без этого скрипт упадёт на втором чанке с ошибкой:
|
||||
Intel oneMKL FATAL ERROR: Cannot load libctranslate2.so
|
||||
|
||||
### Шаг 2. Запуск транскрипции
|
||||
bash /app/hermes_data/skills/meeting-report/scripts/generate_report.sh /app/hermes_data/meetings/2026-03-18
|
||||
|
||||
Для длительных файлов (>30 мин) используется автоматическое разбиение на чанки.
|
||||
|
||||
### Шаг 3. Создание аналитического отчёта
|
||||
После получения transcription/merged_plain.txt:
|
||||
1. Прочитайте транскрипцию
|
||||
2. Создайте report.md со структурой:
|
||||
- Краткая выжимка (1-2 абзаца)
|
||||
- Ход совещания (подробно)
|
||||
- Ключевые решения (список)
|
||||
- Задачи и ответственные
|
||||
- Итоги и следующие шаги
|
||||
|
||||
### Шаг 4. Генерация PDF
|
||||
|
||||
Вариант A: Через pandoc (требует texlive)
|
||||
pandoc report.md -o report.pdf --pdf-engine=xelatex -V mainfont="Noto Sans CJK SC"
|
||||
|
||||
Вариант B: Через weasyprint (проще)
|
||||
pip install weasyprint
|
||||
python3 generate_pdf.py report.md report.pdf
|
||||
|
||||
## Результаты
|
||||
- transcription/merged_plain.txt — полная транскрипция
|
||||
- report.md — аналитический отчёт Markdown
|
||||
- report.pdf — финальный PDF документ
|
||||
|
||||
## Типичные ошибки
|
||||
|
||||
Ошибка: Intel oneMKL FATAL ERROR
|
||||
Решение: Установите MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2
|
||||
|
||||
Ошибка: pandoc command not found
|
||||
Решение: apt-get install pandoc texlive-xetex texlive-lang-cyrillic
|
||||
|
||||
Ошибка: weasyprint command not found
|
||||
Решение: pip install weasyprint
|
||||
|
||||
Ошибка: Нет кириллицы в PDF
|
||||
Решение: Используйте fonts-noto или weasyprint
|
||||
|
||||
## Время обработки
|
||||
- Модель small: 5-10 мин на 10 мин аудио
|
||||
- 1 час аудио = 30-60 минут обработки
|
||||
- Длительные процессы запускайте в фоне: nohup script.sh &
|
||||
179
SKILL.md
179
SKILL.md
|
|
@ -1,94 +1,129 @@
|
|||
---
|
||||
name: meeting-report
|
||||
description: "Генерирует PDF-отчёт по аудиозаписи встречи: расшифровка, аналитический пересказ, ключевые решения и задачи. Использует faster-whisper (model small), ffmpeg, weasyprint."
|
||||
description: Generates meeting reports from audio recordings using local Whisper ASR
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
env:
|
||||
- WHISPER_URL
|
||||
- WHISPER_API_KEY
|
||||
- HOTWORDS_PATH
|
||||
bins:
|
||||
- ffmpeg
|
||||
- ffprobe
|
||||
- pandoc
|
||||
- python3
|
||||
- xelatex
|
||||
primaryEnv: WHISPER_API_KEY
|
||||
emoji: "📝"
|
||||
os:
|
||||
- linux
|
||||
- darwin
|
||||
---
|
||||
|
||||
# Навык: автоматическая генерация отчёта по аудиозаписи встречи
|
||||
# Meeting Report Skill
|
||||
|
||||
## Важные ограничения (читай первым!)
|
||||
- **ОБЯЗАТЕЛЬНО установи переменные окружения** перед запуском транскрипции: `MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2`. Без этого скрипт упадёт на втором чанке с ошибкой "Intel oneMKL FATAL ERROR: Cannot load libctranslate2.so".
|
||||
- **НЕ пытайся запускать `faster-whisper`** как отдельную команду без этих переменных. Это Python-библиотека, вызывается через скрипты (`local_whisper.py`, `transcribe.sh`) с правильными параметрами.
|
||||
- **НЕ изобретай свои собственные команды** для транскрипции или конвертации. Всё уже реализовано в готовых bash-скриптах.
|
||||
- **Единственное, что ты должен сделать** – определить папку с аудио, убедиться, что переменные окружения установлены, и выполнить один скрипт.
|
||||
- **Для генерации PDF:** Если `pandoc` не работает (нет шрифтов кириллицы), используй `weasyprint` (Python пакет).
|
||||
- **Все процессы более 5 минут запускай в фоне** (`nohup ... &`) — execute_code имеет таймаут 5 минут.
|
||||
## Описание / Description
|
||||
Этот навык транскрибирует аудиозаписи совещаний с помощью локально развёрнутого Whisper ASR, объединяет транскрипции из нескольких источников и создаёт структурированный отчёт о встрече в форматах Markdown и PDF.
|
||||
|
||||
## Структура навыка
|
||||
- `scripts/overlay.sh` — обёртка запуска с MKL переменными
|
||||
- `scripts/local_whisper.py` — транскрипция аудио through faster-whisper
|
||||
- `scripts/transcribe_chunked.sh` — транскрипция больших файлов (>30 мин) с разбиением на чанки
|
||||
- `scripts/merge_transcriptions.py` — объединение результатов транскрипции
|
||||
- `scripts/generate_pdf.py` — генерация PDF через weasyprint
|
||||
- `README.md` — полная документация
|
||||
- `QUICKSTART.md` — шпаргалка по быстрому запуску
|
||||
## Триггеры / Triggers
|
||||
- "meeting report", "generate report", "transcribe meeting", "audio report", "отчёт о встрече", "сформируй отчёт", "расшифруй запись"
|
||||
|
||||
## Как именно запускать
|
||||
## Инструкции / Instructions
|
||||
|
||||
### Шаг 0. Определи директорию встречи
|
||||
Пользователь укажет папку с аудиофайлами. По умолчанию:
|
||||
```
|
||||
/app/hermes_data/meetings/YYYY-MM-DD/
|
||||
```
|
||||
Если пользователь указал другой путь – используй его.
|
||||
### 1. Подготовка окружения / Environment setup
|
||||
Убедитесь, что установлены требуемые переменные окружения:
|
||||
- `WHISPER_URL` – URL локального Whisper сервера (например, `http://localhost:8000`)
|
||||
- `WHISPER_API_KEY` – API ключ для доступа к Whisper серверу (Bearer токен)
|
||||
|
||||
### Шаг 1. Проверь наличие аудиофайлов
|
||||
### 2. Запуск генерации отчёта / Generate a report
|
||||
```bash
|
||||
ls /app/hermes_data/meetings/2026-03-18/*.wav
|
||||
bash scripts/generate_report.sh <meeting-date-dir>
|
||||
```
|
||||
|
||||
### Шаг 2. Установи переменные окружения (КРИТИЧНО!)
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
export MKL_SERVICE_FORCE_INTEL=1
|
||||
export OMP_NUM_THREADS=2
|
||||
```
|
||||
Или передай перед python3:
|
||||
```bash
|
||||
MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 local_whisper.py ...
|
||||
bash scripts/generate_report.sh 2026-04-29
|
||||
```
|
||||
Ожидаемая структура:
|
||||
|
||||
### Шаг 3. Запусти транскрипцию
|
||||
Для коротких файлов (<30 мин):
|
||||
```bash
|
||||
bash scripts/overlay.sh /app/hermes_data/meetings/2026-03-18
|
||||
```
|
||||
text
|
||||
2026-04-29/
|
||||
├── *.WAV # исходные аудиофайлы (Saramonic, Zoom H2n)
|
||||
├── transcription/ # создаётся автоматически
|
||||
├── merged.json # объединённые сегменты
|
||||
├── merged_plain.txt # сплошной текст для LLM
|
||||
└── report.pdf # финальный отчёт
|
||||
3. Что делает generate_report.sh
|
||||
Обнаруживает источники – Saramonic (*.WAV), Zoom H2n (SR*XY.WAV, SR*MS.WAV)
|
||||
|
||||
Для длинных файлов (>30 мин):
|
||||
```bash
|
||||
bash scripts/transcribe_chunked.sh /app/hermes_data/meetings/2026-03-18
|
||||
```
|
||||
Конвертирует в MP3 и транскрибирует через Whisper (с поддержкой hotwords)
|
||||
|
||||
### Шаг 4. Создай аналитический отчёт
|
||||
После получения `transcription/merged_plain.txt`:
|
||||
1. Прочитай транскрипцию
|
||||
2. Создай `report.md` со структурой:
|
||||
- Краткая выжимка (1-2 абзаца)
|
||||
- Ход совещания (подробно)
|
||||
- Ключевые решения (список)
|
||||
- Задачи и ответственные (срок действия)
|
||||
- Итоги и следующие шаги
|
||||
Объединяет транскрипции из нескольких источников (основной + аудитория)
|
||||
|
||||
### Шаг 5. Генерация PDF
|
||||
Если pandoc недоступен:
|
||||
```bash
|
||||
python3 scripts/generate_pdf.py report.md report.pdf
|
||||
```
|
||||
Конвертирует готовый report.md в PDF с помощью pandoc и xelatex
|
||||
|
||||
## Типичные ошибки
|
||||
4. Ручное использование скриптов / Manual script usage
|
||||
Транскрипция одного файла:
|
||||
|
||||
| Ошибка | Причина | Решение |
|
||||
|--------|---------|---------|
|
||||
| `Intel oneMKL FATAL ERROR` | Нет переменных окружения | Добавить `MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2` |
|
||||
| `No WAV file found` | Нет аудиофайлов | Положить WAV в папку встречи |
|
||||
| `weasyprint: command not found` | Нет пакета | `pip install weasyprint` |
|
||||
| `ffmpeg: command not found` | Нет ffmpeg | `apt-get install ffmpeg` |
|
||||
bash
|
||||
bash scripts/transcribe.sh <meeting_dir> <audio.WAV> <output_name>
|
||||
Объединение двух JSON транскрипций:
|
||||
|
||||
## Время обработки
|
||||
- Модель small: ~5-10 мин на 10 мин аудио
|
||||
- 1 час аудио = 30-60 минут обработки
|
||||
- Запускай в фоне: `nohup bash script.sh > /tmp/transcribe.log 2>&1 &`
|
||||
- Следи: `tail -f /tmp/transcribe.log`
|
||||
bash
|
||||
python3 scripts/merge_transcriptions.py <primary.json> <secondary.json> <output_dir>
|
||||
Конкатенация WAV в MP3:
|
||||
|
||||
## Результат
|
||||
- `transcription/merged_plain.txt` — полная транскрипция
|
||||
- `report.md` — аналитический отчёт Markdown
|
||||
- `report.pdf` — финальный PDF документ
|
||||
bash
|
||||
bash scripts/concat_wav.sh <output.mp3> <input1.WAV> <input2.WAV> ...
|
||||
Чанковое транскрибирование (очень длинные записи):
|
||||
|
||||
bash
|
||||
bash scripts/transcribe_chunked.sh <input.mp3> <output_name> <output_dir>
|
||||
5. Горячие слова / Hotwords
|
||||
Файл hotwords.txt содержит термины, организацию, имена и аббревиатуры, повышающие точность распознавания. Переопределяется через переменную HOTWORKS_PATH.
|
||||
|
||||
## 6. Инструкции для агента по формированию отчёта / Agent instructions for report generation
|
||||
|
||||
После того как скрипт `generate_report.sh` завершил транскрипцию и объединение источников, агент **обязан** самостоятельно создать файл `report.md` в директории встречи.
|
||||
|
||||
### Требования к отчёту `report.md`
|
||||
|
||||
- **Содержание**: детальное, информативное описание хода встречи, включая:
|
||||
- обсуждавшиеся темы и вопросы,
|
||||
- принятые решения и договорённости,
|
||||
- ход встречи
|
||||
- назначенные задачи и ответственных,
|
||||
- ключевые выводы и следующие шаги.
|
||||
- **Стиль**: корпоративный, деловой, соответствующий IT‑компании (чётко, по делу, без разговорной речи).
|
||||
- **Формат**: Markdown с заголовками, списками, выделением важного.
|
||||
- **Запрещено**: прямые цитаты из транскрипции. Пересказывайте содержание своими словами, сохраняя все смысловые детали.
|
||||
- **Язык**: русский (основной), технические термины допустимы на английском.
|
||||
|
||||
### Как агент должен действовать
|
||||
|
||||
1. **Дождаться завершения работы** `generate_report.sh` (или выполнить его самостоятельно).
|
||||
2. **Прочитать содержимое** файла `merged_plain.txt` (или `merged.txt`), который лежит в папке встречи.
|
||||
Этот файл содержит полный текст транскрипции без таймкодов (или с ними, но для пересказа удобнее plain).
|
||||
3. **Создать (перезаписать) файл** `report.md` в директории `report`, лежащей в папке встречи, следуя указанным выше требованиям.
|
||||
4. **Сгенерировать PDF** одним из способов:
|
||||
- **Способ А (рекомендуемый):** повторно выполнить `bash scripts/generate_report.sh <meeting-date-dir>`.
|
||||
Скрипт обнаружит уже существующий `report.md` и сконвертирует его в `report.pdf`.
|
||||
- **Способ Б:** вручную выполнить команду `pandoc report.md -o report.pdf --pdf-engine=xelatex ...` (параметры см. в скрипте).
|
||||
|
||||
### Пример запроса агента пользователю
|
||||
|
||||
После того как транскрипция готова, агент может написать:
|
||||
|
||||
> Транскрипция встречи завершена. Я подготовлю на её основе детальный отчёт в корпоративном стиле (без прямых цитат) и сохраню его в `report.md`, а затем сконвертирую в PDF. Подождите немного.
|
||||
|
||||
После выполнения агент сообщает: «Отчёт сгенерирован: `/path/to/meeting/report.pdf`».
|
||||
|
||||
Примечания / Notes
|
||||
Скрипты ожидают локальный Whisper сервер, совместимый с API OpenAI (endpoint /v1/audio/transcriptions), с аутентификацией Bearer.
|
||||
|
||||
Для длинных сегментов рекомендуется использовать transcribe_chunked.sh. Он автоматически разбивает аудио по паузам тишины.
|
||||
|
||||
Для PDF требуется установленный pandoc, xelatex и шрифты DejaVu.
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# Concatenate multiple WAV/audio files into a single mp3 using ffmpeg concat demuxer
|
||||
#
|
||||
# Usage: ./concat_wav.sh <output.mp3> <input1.WAV> <input2.WAV> ...
|
||||
# Example: ./concat_wav.sh transcription/saramonic.mp3 20260325-091912.WAV 20260325-095007.WAV
|
||||
# Example: ./concat_wav.sh transcription/saramonic.mp3 20260325-091912.WAV 20260325-095007.WAV 20260325-102102.WAV
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ fi
|
|||
OUTPUT="$1"
|
||||
shift
|
||||
|
||||
# Build concat list file
|
||||
LISTFILE=$(mktemp /tmp/ffmpeg_concat_XXXXXX.txt)
|
||||
trap "rm -f '$LISTFILE'" EXIT
|
||||
|
||||
|
|
@ -23,6 +24,8 @@ for f in "$@"; do
|
|||
done
|
||||
|
||||
echo "Concatenating $# files -> $OUTPUT"
|
||||
cat "$LISTFILE"
|
||||
|
||||
ffmpeg -y -f concat -safe 0 -i "$LISTFILE" -ac 1 -ar 16000 -b:a 64k "$OUTPUT" 2>/dev/null
|
||||
|
||||
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT" | cut -d. -f1)
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Генерация PDF из Markdown через weasyprint.
|
||||
|
||||
УСТАНОВКА: pip install weasyprint
|
||||
ИСПОЛЬЗОВАНИЕ: python3 generate_pdf.py report.md report.pdf
|
||||
"""
|
||||
|
||||
import sys
|
||||
from weasyprint import HTML
|
||||
|
||||
def markdown_to_pdf(md_path, pdf_path):
|
||||
"""Конвертирует Markdown в PDF через HTML."""
|
||||
with open(md_path, 'r', encoding='utf-8') as f:
|
||||
md_content = f.read()
|
||||
|
||||
# Simple MD to HTML conversion
|
||||
html_content = f"""
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {{ margin: 2cm; }}
|
||||
body {{ font-family: sans-serif; line-height: 1.6; }}
|
||||
h1 {{ color: #2c3e50; border-bottom: 2px solid #3498db; }}
|
||||
h2 {{ color: #34495e; border-bottom: 1px solid #bdc3c7; }}
|
||||
table {{ border-collapse: collapse; width: 100%; }}
|
||||
th, td {{ border: 1px solid #ddd; padding: 8px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{md_content.replace('## ', '<h2>').replace('# ', '<h1>')}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
HTML(string=html_content).write_pdf(pdf_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 generate_pdf.py <report.md> <output.pdf>")
|
||||
sys.exit(1)
|
||||
|
||||
markdown_to_pdf(sys.argv[1], sys.argv[2])
|
||||
print(f"PDF created: {sys.argv[2]}")
|
||||
|
|
@ -1,60 +1,73 @@
|
|||
#!/bin/bash
|
||||
# generate_report.sh — Full pipeline for generating meeting report (without diagrams)
|
||||
# Usage: ./generate_report.sh /absolute/path/to/meeting_folder
|
||||
# Example: ./generate_report.sh /app/hermes_data/meetings/2026-04-15
|
||||
# generate_report.sh — Simplified pipeline: transcription + merge + PDF
|
||||
# Usage: ./generate_report.sh <meeting-date-dir>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Load .env if exists (for hotwords etc.)
|
||||
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
set -a
|
||||
source "$SCRIPT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <absolute_path_to_meeting_folder>"
|
||||
echo "Example: $0 /app/hermes_data/meetings/2026-04-15"
|
||||
echo "Usage: $0 <meeting-date-dir>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MEETING_DIR="$1"
|
||||
|
||||
# If relative path provided, convert to absolute
|
||||
if [[ "$MEETING_DIR" != /* ]]; then
|
||||
MEETING_DIR="$SCRIPT_DIR/$MEETING_DIR"
|
||||
fi
|
||||
|
||||
# Resolve absolute path
|
||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
||||
MEETING_DIR="$SCRIPT_DIR/$1"
|
||||
TRANSCRIPTION_DIR="$MEETING_DIR/transcription"
|
||||
DIAGRAMS_DIR="$MEETING_DIR/diagrams"
|
||||
|
||||
if [ ! -d "$MEETING_DIR" ]; then
|
||||
echo "Error: Meeting directory not found: $MEETING_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# ------------------------------------------------------------
|
||||
# Step 1: Transcription (skip if already done)
|
||||
# ============================================================
|
||||
if [ -d "$MEETING_DIR/transcription" ] && [ -f "$MEETING_DIR/transcription/plain_text.txt" ]; then
|
||||
echo "[1/2] Transcription already exists, skipping."
|
||||
# ------------------------------------------------------------
|
||||
if [ -d "$TRANSCRIPTION_DIR" ] && [ -f "$TRANSCRIPTION_DIR/plain_text.txt" ]; then
|
||||
echo "[1/3] Transcription already exists, skipping."
|
||||
else
|
||||
echo "[1/2] Running transcription..."
|
||||
echo "[1/3] Running transcription..."
|
||||
bash "$SCRIPT_DIR/transcribe.sh" "$MEETING_DIR"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Step 2: Generate PDF from markdown
|
||||
# ============================================================
|
||||
echo "[2/2] Generating PDF..."
|
||||
# ------------------------------------------------------------
|
||||
# Step 2: Merge transcriptions (if both saramonic and h2n exist)
|
||||
# ------------------------------------------------------------
|
||||
echo "[2/3] Merging transcriptions where possible..."
|
||||
if [ -f "$TRANSCRIPTION_DIR/saramonic.json" ] && [ -f "$TRANSCRIPTION_DIR/h2n_xy.json" ]; then
|
||||
echo " Merging saramonic (primary) + h2n_xy (secondary) → merged.json"
|
||||
python3 "$SCRIPT_DIR/merge_transcriptions.py" \
|
||||
"$TRANSCRIPTION_DIR/saramonic.json" \
|
||||
"$TRANSCRIPTION_DIR/h2n_xy.json" \
|
||||
"$MEETING_DIR"
|
||||
elif [ -f "$TRANSCRIPTION_DIR/saramonic.json" ] && [ -f "$TRANSCRIPTION_DIR/h2n_ms.json" ]; then
|
||||
echo " Merging saramonic (primary) + h2n_ms (secondary) → merged.json"
|
||||
python3 "$SCRIPT_DIR/merge_transcriptions.py" \
|
||||
"$TRANSCRIPTION_DIR/saramonic.json" \
|
||||
"$TRANSCRIPTION_DIR/h2n_ms.json" \
|
||||
"$MEETING_DIR"
|
||||
else
|
||||
echo " Only one transcription source found or none — copying plain text to merged_plain.txt"
|
||||
# Try to find any existing plain text
|
||||
PLAIN_SRC=$(find "$TRANSCRIPTION_DIR" -name "*_plain.txt" | head -1)
|
||||
if [ -n "$PLAIN_SRC" ]; then
|
||||
cp "$PLAIN_SRC" "$MEETING_DIR/merged_plain.txt"
|
||||
echo " Copied $PLAIN_SRC -> merged_plain.txt"
|
||||
else
|
||||
echo " No plain text found yet — will be created after transcription finishes."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Step 3: Generate PDF from report.md (if exists)
|
||||
# ------------------------------------------------------------
|
||||
echo "[3/3] Generating PDF from report.md (if present)..."
|
||||
REPORT_MD="$MEETING_DIR/report.md"
|
||||
REPORT_PDF="$MEETING_DIR/report.pdf"
|
||||
|
||||
if [ ! -f "$REPORT_MD" ]; then
|
||||
echo " Error: report.md not found at $REPORT_MD"
|
||||
exit 1
|
||||
echo " No report.md found. Agent must write this file first."
|
||||
echo " After writing the report, run: pandoc $REPORT_MD -o $REPORT_PDF ..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$MEETING_DIR"
|
||||
|
|
|
|||
|
|
@ -35,4 +35,4 @@ Wildberries, Хабр
|
|||
тимлид, календарный план, конгломерат, квиз
|
||||
стартап, деплой, инфраструктура, безопасность
|
||||
IT-льготы, GitHub, open source, VPS
|
||||
петличка, Saramonic, диктофон, скрипт
|
||||
петличка, Saramonic, диктофон
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Транскрипция аудио через faster-whisper с исправлением MKL ошибки.
|
||||
|
||||
ИСПОЛЬЗОВАНИЕ:
|
||||
import os
|
||||
os.environ["MKL_SERVICE_FORCE_INTEL"] = "1"
|
||||
os.environ["OMP_NUM_THREADS"] = "2"
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
model = WhisperModel("small")
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# CRITICAL: Must be set BEFORE importing faster_whisper
|
||||
os.environ["MKL_SERVICE_FORCE_INTEL"] = "1"
|
||||
os.environ["OMP_NUM_THREADS"] = "2"
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
def transcribe_audio(audio_path, model_size="small", language="ru"):
|
||||
"""Транскрибирует аудиофайл."""
|
||||
print(f"Loading model {model_size}...")
|
||||
model = WhisperModel(model_size)
|
||||
|
||||
print(f"Transcribing {audio_path}...")
|
||||
segments, _ = model.transcribe(audio_path, language=language)
|
||||
|
||||
# Convert to list for proper handling
|
||||
segments = list(segments)
|
||||
|
||||
return segments
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 local_whisper.py <audio.wav>")
|
||||
sys.exit(1)
|
||||
|
||||
segments = transcribe_audio(sys.argv[1])
|
||||
|
||||
for segment in segments:
|
||||
print(segment.text)
|
||||
|
|
@ -1,25 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Объединение транскрипций из нескольких файлов."""
|
||||
"""
|
||||
Merge two transcription sources by timestamps.
|
||||
|
||||
Primary source (e.g., lavalier mic / Saramonic) — better quality for main speaker.
|
||||
Secondary source (e.g., room mic / H2n XY) — captures audience/student voices.
|
||||
|
||||
Strategy:
|
||||
1. Both sources have timestamped segments from Whisper.
|
||||
2. For each secondary segment, check if primary has a similar segment at the same time.
|
||||
3. If primary has coverage (overlapping segment exists) — keep primary's version.
|
||||
4. If primary has NO coverage (gap) — insert secondary segment, tagged as [audience].
|
||||
5. Time alignment: the two recordings may have different start times.
|
||||
We detect the offset by cross-correlating the first few segments' text.
|
||||
|
||||
Usage:
|
||||
python3 merge_transcriptions.py <primary.json> <secondary.json> <output_dir> [--offset SECONDS]
|
||||
|
||||
Output:
|
||||
<output_dir>/merged.json — combined segments with source tags
|
||||
<output_dir>/merged.txt — timestamped text
|
||||
<output_dir>/merged_plain.txt — plain text for LLM processing
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
def merge_transcriptions(timeline_dir, output_path="merged_plain.txt"):
|
||||
"""Собирает все .txt файлы в один."""
|
||||
txt_files = sorted([f for f in os.listdir(timeline_dir) if f.endswith('.txt') and 'merged' not in f])
|
||||
|
||||
def load_segments(json_path):
|
||||
"""Load segments from whisper JSON output."""
|
||||
with open(json_path) as f:
|
||||
data = json.load(f)
|
||||
segments = []
|
||||
for seg in data.get("segments", []):
|
||||
segments.append({
|
||||
"start": seg.get("start", 0),
|
||||
"end": seg.get("end", 0),
|
||||
"text": seg.get("text", "").strip(),
|
||||
})
|
||||
return segments, data.get("duration", 0)
|
||||
|
||||
|
||||
def estimate_offset(primary_segs, secondary_segs, search_window=120):
|
||||
"""
|
||||
Estimate time offset between two recordings.
|
||||
Returns offset such that: secondary_time + offset ≈ primary_time
|
||||
Uses text similarity of segments within search window.
|
||||
"""
|
||||
if not primary_segs or not secondary_segs:
|
||||
return 0.0
|
||||
|
||||
best_offset = 0.0
|
||||
best_score = 0.0
|
||||
|
||||
# Try offsets in 1-second steps within search window
|
||||
for offset_int in range(-search_window, search_window + 1):
|
||||
offset = float(offset_int)
|
||||
score = 0.0
|
||||
comparisons = 0
|
||||
|
||||
for p_seg in primary_segs[:30]: # check first 30 primary segments
|
||||
p_mid = (p_seg["start"] + p_seg["end"]) / 2
|
||||
# Find closest secondary segment at (p_mid - offset)
|
||||
target_time = p_mid - offset
|
||||
best_match = None
|
||||
best_dist = float("inf")
|
||||
|
||||
for s_seg in secondary_segs[:40]:
|
||||
s_mid = (s_seg["start"] + s_seg["end"]) / 2
|
||||
dist = abs(s_mid - target_time)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_match = s_seg
|
||||
|
||||
if best_match and best_dist < 15: # within 15 seconds
|
||||
sim = SequenceMatcher(
|
||||
None, p_seg["text"].lower(), best_match["text"].lower()
|
||||
).ratio()
|
||||
score += sim
|
||||
comparisons += 1
|
||||
|
||||
if comparisons > 0:
|
||||
score /= comparisons
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_offset = offset
|
||||
|
||||
return best_offset
|
||||
|
||||
|
||||
def merge(primary_segs, secondary_segs, offset=0.0, gap_threshold=3.0, sim_threshold=0.3):
|
||||
"""
|
||||
Merge primary and secondary segments.
|
||||
|
||||
Args:
|
||||
primary_segs: segments from primary source (lavalier)
|
||||
secondary_segs: segments from secondary source (room mic)
|
||||
offset: time offset to add to secondary timestamps to align with primary
|
||||
gap_threshold: minimum gap (seconds) in primary to consider inserting secondary
|
||||
sim_threshold: below this similarity, secondary segment is considered unique content
|
||||
"""
|
||||
merged = []
|
||||
for txt_file in txt_files:
|
||||
with open(os.path.join(timeline_dir, txt_file), 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
merged.append(f"--- {txt_file} ---\n{content}\n")
|
||||
|
||||
with open(os.path.join(timeline_dir, output_path), 'w', encoding='utf-8') as f:
|
||||
f.write('\n\n'.join(merged))
|
||||
# Add source tag to primary segments
|
||||
for seg in primary_segs:
|
||||
merged.append({
|
||||
**seg,
|
||||
"source": "primary",
|
||||
})
|
||||
|
||||
# Build primary timeline: list of (start, end) intervals
|
||||
primary_intervals = [(s["start"], s["end"]) for s in primary_segs]
|
||||
|
||||
def primary_covers(t_start, t_end):
|
||||
"""Check if primary has any segment overlapping [t_start, t_end]."""
|
||||
for p_start, p_end in primary_intervals:
|
||||
if p_start <= t_end and p_end >= t_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_similar_primary(text, t_start, t_end, window=10):
|
||||
"""Find most similar primary segment near the given time."""
|
||||
best_sim = 0.0
|
||||
for seg in primary_segs:
|
||||
if abs(seg["start"] - t_start) > window and abs(seg["end"] - t_end) > window:
|
||||
continue
|
||||
sim = SequenceMatcher(None, text.lower(), seg["text"].lower()).ratio()
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
return best_sim
|
||||
|
||||
# Check each secondary segment
|
||||
inserted = 0
|
||||
for seg in secondary_segs:
|
||||
adj_start = seg["start"] + offset
|
||||
adj_end = seg["end"] + offset
|
||||
text = seg["text"]
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Check if primary already covers this time range
|
||||
if primary_covers(adj_start, adj_end):
|
||||
# Primary has something here — check if it's the same content
|
||||
sim = find_similar_primary(text, adj_start, adj_end)
|
||||
if sim >= sim_threshold:
|
||||
continue # primary already has this, skip
|
||||
|
||||
# This segment is unique to secondary (likely audience voice)
|
||||
merged.append({
|
||||
"start": round(adj_start, 2),
|
||||
"end": round(adj_end, 2),
|
||||
"text": text,
|
||||
"source": "secondary",
|
||||
})
|
||||
inserted += 1
|
||||
|
||||
# Sort by start time
|
||||
merged.sort(key=lambda s: s["start"])
|
||||
|
||||
return merged, inserted
|
||||
|
||||
|
||||
def format_timestamp(seconds):
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = int(seconds % 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Merge two transcription sources")
|
||||
parser.add_argument("primary", help="Primary source JSON (lavalier/Saramonic)")
|
||||
parser.add_argument("secondary", help="Secondary source JSON (room mic/H2n)")
|
||||
parser.add_argument("output_dir", help="Output directory")
|
||||
parser.add_argument("--offset", type=float, default=None,
|
||||
help="Time offset (seconds) to add to secondary timestamps. "
|
||||
"Auto-detected if not specified.")
|
||||
parser.add_argument("--gap-threshold", type=float, default=3.0,
|
||||
help="Minimum gap in primary to insert secondary (default: 3.0)")
|
||||
parser.add_argument("--sim-threshold", type=float, default=0.3,
|
||||
help="Similarity threshold below which secondary is unique (default: 0.3)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Primary: {args.primary}")
|
||||
print(f"Secondary: {args.secondary}")
|
||||
|
||||
primary_segs, primary_dur = load_segments(args.primary)
|
||||
secondary_segs, secondary_dur = load_segments(args.secondary)
|
||||
print(f"Primary: {len(primary_segs)} segments, {primary_dur:.0f}s")
|
||||
print(f"Secondary: {len(secondary_segs)} segments, {secondary_dur:.0f}s")
|
||||
|
||||
# Estimate or use provided offset
|
||||
if args.offset is not None:
|
||||
offset = args.offset
|
||||
print(f"Using provided offset: {offset:+.1f}s")
|
||||
else:
|
||||
print("Estimating time offset...")
|
||||
offset = estimate_offset(primary_segs, secondary_segs)
|
||||
print(f"Estimated offset: {offset:+.1f}s (secondary is {abs(offset):.0f}s "
|
||||
f"{'behind' if offset > 0 else 'ahead of'} primary)")
|
||||
|
||||
# Merge
|
||||
merged, inserted = merge(
|
||||
primary_segs, secondary_segs,
|
||||
offset=offset,
|
||||
gap_threshold=args.gap_threshold,
|
||||
sim_threshold=args.sim_threshold,
|
||||
)
|
||||
print(f"Merged: {len(merged)} segments ({len(primary_segs)} primary + {inserted} from secondary)")
|
||||
|
||||
# Write outputs
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
# JSON
|
||||
json_path = os.path.join(args.output_dir, "merged.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump({"segments": merged, "offset": offset}, f, ensure_ascii=False, indent=2)
|
||||
print(f"Written: {json_path}")
|
||||
|
||||
# Timestamped text
|
||||
txt_path = os.path.join(args.output_dir, "merged.txt")
|
||||
with open(txt_path, "w") as f:
|
||||
for seg in merged:
|
||||
tag = "" if seg["source"] == "primary" else " [аудитория]"
|
||||
f.write(f"[{format_timestamp(seg['start'])}]{tag} {seg['text']}\n")
|
||||
print(f"Written: {txt_path}")
|
||||
|
||||
# Plain text
|
||||
plain_path = os.path.join(args.output_dir, "merged_plain.txt")
|
||||
with open(plain_path, "w") as f:
|
||||
f.write(" ".join(seg["text"] for seg in merged))
|
||||
print(f"Written: {plain_path}")
|
||||
|
||||
print(f"Merged {len(txt_files)} files into {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
dir_path = sys.argv[1] if len(sys.argv) > 1 else "transcription"
|
||||
merge_transcriptions(dir_path)
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Обёртка для запуска транскрипции с правильной настройкой окружения
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# CRITICAL: переменные для Intel oneMKL
|
||||
export MKL_SERVICE_FORCE_INTEL=1
|
||||
export OMP_NUM_THREADS=2
|
||||
|
||||
MEETING_DIR="${1:-.}"
|
||||
|
||||
if [ ! -f "$MEETING_DIR"/*.wav ] && [ ! -f "$MEETING_DIR"/*.WAV ]; then
|
||||
echo "Error: No WAV file found in $MEETING_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Check audio duration
|
||||
DURATION=$(ffprobe -i "$MEETING_DIR"/*.wav -show_entries format=duration -v quiet -of csv="p=0" 2>/dev/null | cut -d. -f1)
|
||||
|
||||
if [ $DURATION -gt 1800 ]; then # >30 минут
|
||||
echo "Audio is $DURATION seconds. Using chunked transcription..."
|
||||
bash "$SCRIPT_DIR/transcribe_chunked.sh" "$MEETING_DIR"
|
||||
else
|
||||
echo "Audio is $DURATION seconds. Using standard transcription..."
|
||||
bash "$SCRIPT_DIR/transcribe.sh" "$MEETING_DIR"
|
||||
fi
|
||||
|
||||
echo "Transcription complete. Check $MEETING_DIR/transcription/"
|
||||
|
|
@ -1,59 +1,42 @@
|
|||
#!/bin/bash
|
||||
# Transcribe audio recordings using local faster-whisper
|
||||
# Supports multiple sources: Zoom H2n (4ch WAV), Saramonic (mono WAV), etc.
|
||||
#
|
||||
# Usage:
|
||||
# ./transcribe.sh /absolute/path/to/meeting_folder
|
||||
# ./transcribe.sh /absolute/path/to/meeting_folder specific.WAV output_name
|
||||
#
|
||||
# Examples:
|
||||
# ./transcribe.sh /app/hermes_data/meetings/2026-02-18
|
||||
# ./transcribe.sh /app/hermes_data/meetings/2026-02-18 SR003XY.WAV h2n_xy
|
||||
# Transcribe audio recordings using local whisper server (with API key)
|
||||
# Usage: ./transcribe.sh <meeting_dir> [<file.WAV> <output_name>]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WHISPER_MODEL="base"
|
||||
: ${WHISPER_URL:?ERROR: WHISPER_URL not set (e.g., https://llm.lambda.coredump.ru/v1)}
|
||||
: ${WHISPER_API_KEY:?ERROR: WHISPER_API_KEY not set}
|
||||
MODEL="whisper-1" # Изменено с конкретной модели на общее название
|
||||
LANGUAGE="ru"
|
||||
|
||||
# Load hotwords
|
||||
HOTWORDS_FILE="$SCRIPT_DIR/hotwords.txt"
|
||||
# Hotwords
|
||||
HOTWORDS_FILE="${HOTWORKS_PATH:-$SCRIPT_DIR/hotwords.txt}"
|
||||
HOTWORDS=""
|
||||
if [ -f "$HOTWORDS_FILE" ]; then
|
||||
HOTWORDS=$(grep -v '^#' "$HOTWORDS_FILE" | grep -v '^$' | tr '\n' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
|
||||
echo "Loaded hotwords from $HOTWORDS_FILE"
|
||||
else
|
||||
HOTWORDS=""
|
||||
echo "Warning: hotwords.txt not found, proceeding without hotwords"
|
||||
fi
|
||||
|
||||
# ---------- argument parsing ----------
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <absolute_meeting_dir> [<file.WAV> <output_name>]"
|
||||
echo "Example: $0 /app/hermes_data/meetings/2026-02-18"
|
||||
echo "Usage: $0 <meeting_dir> [<file.WAV> <output_name>]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MEETING_DIR="$1"
|
||||
if [[ "$MEETING_DIR" != /* ]]; then
|
||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
||||
else
|
||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
||||
fi
|
||||
|
||||
WORK_DIR="$MEETING_DIR"
|
||||
WORK_DIR="$(cd "$SCRIPT_DIR/$MEETING_DIR" && pwd)"
|
||||
OUTPUT_DIR="$WORK_DIR/transcription"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Function: convert WAV(s) to mono mp3
|
||||
convert_to_mp3() {
|
||||
local output_mp3="$1"
|
||||
shift
|
||||
local inputs=("$@")
|
||||
|
||||
if [ -f "$output_mp3" ]; then
|
||||
echo " $output_mp3 already exists, skipping conversion"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ ${#inputs[@]} -eq 1 ]; then
|
||||
echo " Converting ${inputs[0]} -> $output_mp3"
|
||||
ffmpeg -y -i "${inputs[0]}" -ac 1 -ar 16000 -b:a 64k "$output_mp3" 2>/dev/null
|
||||
|
|
@ -67,13 +50,8 @@ convert_to_mp3() {
|
|||
ffmpeg -y -f concat -safe 0 -i "$listfile" -ac 1 -ar 16000 -b:a 64k "$output_mp3" 2>/dev/null
|
||||
rm -f "$listfile"
|
||||
fi
|
||||
|
||||
local dur
|
||||
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$output_mp3" | cut -d. -f1)
|
||||
echo " Duration: ${dur}s ($(( dur / 60 ))m$(( dur % 60 ))s)"
|
||||
}
|
||||
|
||||
# Function: transcribe using local faster-whisper (with chunking if needed)
|
||||
transcribe_file() {
|
||||
local mp3_file="$1"
|
||||
local name="$2"
|
||||
|
|
@ -84,21 +62,42 @@ transcribe_file() {
|
|||
return
|
||||
fi
|
||||
|
||||
# Check duration of mp3
|
||||
local duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$mp3_file" | cut -d. -f1)
|
||||
if [ "$duration" -gt 1800 ]; then # > 30 minutes
|
||||
echo " Audio is ${duration}s long (>30 min), using chunked transcription..."
|
||||
bash "$SCRIPT_DIR/transcribe_chunked.sh" "$mp3_file" "$name" "$OUTPUT_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
echo " Transcribing $name (local faster-whisper)..."
|
||||
echo " Transcribing $name..."
|
||||
local started
|
||||
started=$(date +%s)
|
||||
|
||||
MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 "$SCRIPT_DIR/local_whisper.py" "$mp3_file" "$json_file" "$WHISPER_MODEL" "$HOTWORDS"
|
||||
# Form the full URL for transcription
|
||||
local full_url="${WHISPER_URL}/audio/transcriptions"
|
||||
|
||||
local curl_args=(
|
||||
-s -w "%{http_code}" -o "$json_file"
|
||||
-X POST "$full_url"
|
||||
-H "Authorization: Bearer $WHISPER_API_KEY"
|
||||
-F "file=@${mp3_file}"
|
||||
-F "model=${MODEL}"
|
||||
-F "language=${LANGUAGE}"
|
||||
-F "response_format=verbose_json"
|
||||
-F "temperature=0.0"
|
||||
--max-time 3600
|
||||
)
|
||||
if [ -n "$HOTWORDS" ]; then
|
||||
curl_args+=(-F "hotwords=${HOTWORDS}")
|
||||
fi
|
||||
|
||||
local http_code
|
||||
http_code=$(curl "${curl_args[@]}")
|
||||
local elapsed=$(( $(date +%s) - started ))
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo " ERROR: HTTP $http_code"
|
||||
# Display error response body for debugging
|
||||
if [ -f "$json_file" ]; then
|
||||
cat "$json_file"
|
||||
fi
|
||||
rm -f "$json_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Done in ${elapsed}s"
|
||||
|
||||
# Extract plain text and timestamped text
|
||||
|
|
@ -135,7 +134,7 @@ print(f" {len(segs)} segments, {len(plain)} chars")
|
|||
PYEOF
|
||||
}
|
||||
|
||||
# Manual mode: specific file
|
||||
# ---------- manual mode (specific file) ----------
|
||||
if [ $# -ge 3 ]; then
|
||||
WAV_FILE="$WORK_DIR/$2"
|
||||
NAME="$3"
|
||||
|
|
@ -148,14 +147,11 @@ if [ $# -ge 3 ]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Auto mode: detect and transcribe all sources
|
||||
# ---------- auto mode ----------
|
||||
echo "=== Auto-detecting audio sources in $WORK_DIR ==="
|
||||
|
||||
# Detect H2n files (SR*XY.WAV, SR*MS.WAV)
|
||||
H2N_XY=$(find "$WORK_DIR" -maxdepth 1 -name "SR*XY.WAV" | head -1)
|
||||
H2N_MS=$(find "$WORK_DIR" -maxdepth 1 -name "SR*MS.WAV" | head -1)
|
||||
|
||||
# Detect Saramonic / other timestamped WAV files (not SR*)
|
||||
mapfile -t SARAMONIC_FILES < <(find "$WORK_DIR" -maxdepth 1 -name "*.WAV" ! -name "SR*" | sort)
|
||||
|
||||
SOURCES=()
|
||||
|
|
@ -202,9 +198,3 @@ done
|
|||
echo ""
|
||||
echo "=== Done! ==="
|
||||
echo "Results in: $OUTPUT_DIR/"
|
||||
for entry in "${SOURCES[@]}"; do
|
||||
name="${entry%%:*}"
|
||||
echo " ${name}.json - whisper JSON with segments"
|
||||
echo " ${name}.txt - timestamped transcription"
|
||||
echo " ${name}_plain.txt - plain text"
|
||||
done
|
||||
|
|
@ -1,64 +1,197 @@
|
|||
#!/bin/bash
|
||||
# Транскрипция с разбивкой на чанки для длинных аудио
|
||||
# Transcribe long audio by splitting at silence boundaries
|
||||
# Uses Whisper API with authentication
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MEETING_DIR="${1:-.}"
|
||||
CHUNKS_DIR="$MEETING_DIR/transcription"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
: ${WHISPER_URL:?ERROR: WHISPER_URL not set}
|
||||
: ${WHISPER_API_KEY:?ERROR: WHISPER_API_KEY not set}
|
||||
MODEL="whisper-1" # Изменено с конкретной модели на общее название
|
||||
LANGUAGE="ru"
|
||||
TARGET_CHUNK=600
|
||||
|
||||
HOTWORDS_FILE="${HOTWORKS_PATH:-$SCRIPT_DIR/hotwords.txt}"
|
||||
HOTWORDS=""
|
||||
if [ -f "$HOTWORDS_FILE" ]; then
|
||||
HOTWORDS=$(grep -v '^#' "$HOTWORDS_FILE" | grep -v '^$' | tr '\n' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
|
||||
fi
|
||||
|
||||
MP3_FILE="$1"
|
||||
NAME="$2"
|
||||
OUTPUT_DIR="$3"
|
||||
CHUNKS_DIR="$OUTPUT_DIR/chunks_${NAME}"
|
||||
|
||||
if [ -f "$OUTPUT_DIR/${NAME}.json" ]; then
|
||||
echo "$NAME already transcribed, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$CHUNKS_DIR"
|
||||
|
||||
# Get audio file
|
||||
WAV_FILE=$(ls "$MEETING_DIR"/*.wav 2>/dev/null || ls "$MEETING_DIR"/*.WAV 2>/dev/null)
|
||||
if [ -z "$WAV_FILE" ] || [ ! -f "$WAV_FILE" ]; then
|
||||
echo "Error: No WAV file found"
|
||||
exit 1
|
||||
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$MP3_FILE" | cut -d. -f1)
|
||||
echo "=== Chunked transcription: $NAME ($DURATION s / $((DURATION/60))m) ==="
|
||||
|
||||
# Find silence gaps
|
||||
SILENCES_FILE="$CHUNKS_DIR/silences.txt"
|
||||
if [ ! -f "$SILENCES_FILE" ]; then
|
||||
ffmpeg -i "$MP3_FILE" -af "silencedetect=noise=-35dB:d=0.5" -f null - 2>&1 \
|
||||
| grep "silence_end" \
|
||||
| sed 's/.*silence_end: \([0-9.]*\).*/\1/' \
|
||||
> "$SILENCES_FILE"
|
||||
fi
|
||||
echo " Found $(wc -l < "$SILENCES_FILE") silence gaps"
|
||||
|
||||
# Duration
|
||||
DURATION=$(ffprobe -i "$WAV_FILE" -show_entries format=duration -v quiet -of csv="p=0")
|
||||
# Compute split points
|
||||
SPLIT_POINTS=$(python3 - "$SILENCES_FILE" "$TARGET_CHUNK" "$DURATION" <<'PYEOF'
|
||||
import sys
|
||||
silences_file = sys.argv[1]
|
||||
target = float(sys.argv[2])
|
||||
duration = float(sys.argv[3])
|
||||
|
||||
echo "Audio duration: $DURATION seconds"
|
||||
with open(silences_file) as f:
|
||||
silences = [float(line.strip()) for line in f if line.strip()]
|
||||
|
||||
# Chunk settings
|
||||
chunk_duration=600
|
||||
offset=0
|
||||
chunk_num=0
|
||||
if not silences:
|
||||
n = max(2, int(duration / target))
|
||||
splits = [duration * i / n for i in range(1, n)]
|
||||
else:
|
||||
splits = []
|
||||
t = target
|
||||
while t < duration - 30:
|
||||
best = min(silences, key=lambda s: abs(s - t))
|
||||
if not splits or best > splits[-1] + 30:
|
||||
splits.append(best)
|
||||
t += target
|
||||
|
||||
echo "Extracting chunks..."
|
||||
print(" ".join(f"{s:.2f}" for s in splits))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
while (( $(echo "$offset < $DURATION" | bc -l) )); do
|
||||
chunk_file="$CHUNKS_DIR/chunk_${chunk_num}.wav"
|
||||
echo "Extracting chunk $chunk_num at offset $offset..."
|
||||
IFS=' ' read -ra POINTS <<< "$SPLIT_POINTS"
|
||||
N_CHUNKS=$((${#POINTS[@]} + 1))
|
||||
echo " Will create $N_CHUNKS chunks"
|
||||
|
||||
# Retry logic
|
||||
for attempt in 1 2 3; do
|
||||
if ffmpeg -i "$WAV_FILE" -ss "$offset" -t "$chunk_duration" -acodec pcm_s16le -ar 16000 "$chunk_file" -y 2>/dev/null; then
|
||||
break
|
||||
elif [ $attempt -eq 3 ]; then
|
||||
echo "Error: Failed to extract chunk $chunk_num"
|
||||
exit 1
|
||||
# Split audio
|
||||
PREV=0
|
||||
for i in $(seq 0 $((N_CHUNKS - 1))); do
|
||||
CHUNK_FILE="$CHUNKS_DIR/chunk_$(printf '%03d' $i).mp3"
|
||||
if [ -f "$CHUNK_FILE" ] && [ $(stat -c%s "$CHUNK_FILE") -gt 1000 ]; then
|
||||
if [ $i -lt ${#POINTS[@]} ]; then
|
||||
PREV="${POINTS[$i]}"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " chunk_$(printf '%03d' $i): exists, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
offset=$((offset + chunk_duration))
|
||||
((chunk_num++))
|
||||
if [ $i -lt ${#POINTS[@]} ]; then
|
||||
END="${POINTS[$i]}"
|
||||
DUR=$(python3 -c "print(f'{$END - $PREV:.2f}')")
|
||||
ffmpeg -y -i "$MP3_FILE" -ss "$PREV" -t "$DUR" -c copy "$CHUNK_FILE" 2>/dev/null
|
||||
PREV="$END"
|
||||
else
|
||||
ffmpeg -y -i "$MP3_FILE" -ss "$PREV" -c copy "$CHUNK_FILE" 2>/dev/null
|
||||
fi
|
||||
CHUNK_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$CHUNK_FILE" | cut -d. -f1)
|
||||
echo " chunk_$(printf '%03d' $i): ${CHUNK_DUR}s"
|
||||
done
|
||||
|
||||
echo "Transcribing $chunk_num chunks..."
|
||||
|
||||
# Transcribe each chunk
|
||||
for i in $(seq 0 $((chunk_num - 1))); do
|
||||
chunk_file="$CHUNKS_DIR/chunk_${i}.wav"
|
||||
output_file="$CHUNKS_DIR/chunk_${i}.txt"
|
||||
echo "Transcribing chunks..."
|
||||
for i in $(seq 0 $((N_CHUNKS - 1))); do
|
||||
CHUNK_FILE="$CHUNKS_DIR/chunk_$(printf '%03d' $i).mp3"
|
||||
CHUNK_JSON="$CHUNKS_DIR/chunk_$(printf '%03d' $i).json"
|
||||
|
||||
echo "Transcribing chunk $i..."
|
||||
MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 "${BASH_SOURCE[0]%/*}/local_whisper.py" "$chunk_file" > "$output_file"
|
||||
if [ -f "$CHUNK_JSON" ]; then
|
||||
echo " chunk_$(printf '%03d' $i): already transcribed"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -n " chunk_$(printf '%03d' $i): transcribing... "
|
||||
STARTED=$(date +%s)
|
||||
|
||||
full_url="${WHISPER_URL}/audio/transcriptions"
|
||||
|
||||
CURL_ARGS=(
|
||||
-s -w "%{http_code}" -o "$CHUNK_JSON"
|
||||
-X POST "$full_url"
|
||||
-H "Authorization: Bearer $WHISPER_API_KEY"
|
||||
-F "file=@${CHUNK_FILE}"
|
||||
-F "model=${MODEL}"
|
||||
-F "language=${LANGUAGE}"
|
||||
-F "response_format=verbose_json"
|
||||
-F "temperature=0.0"
|
||||
--max-time 600
|
||||
)
|
||||
[ -n "$HOTWORDS" ] && CURL_ARGS+=(-F "hotwords=${HOTWORDS}")
|
||||
|
||||
HTTP_CODE=$(curl "${CURL_ARGS[@]}")
|
||||
ELAPSED=$(( $(date +%s) - STARTED ))
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "ERROR (HTTP $HTTP_CODE)"
|
||||
if [ -f "$CHUNK_JSON" ]; then
|
||||
cat "$CHUNK_JSON"
|
||||
fi
|
||||
rm -f "$CHUNK_JSON"
|
||||
exit 1
|
||||
fi
|
||||
echo "done in ${ELAPSED}s"
|
||||
done
|
||||
|
||||
# Merge
|
||||
echo "Merging transcriptions..."
|
||||
cat "$CHUNKS_DIR"/chunk_*.txt > "$CHUNKS_DIR/merged_raw.txt"
|
||||
# Merge chunks into final JSON
|
||||
echo "Merging chunks..."
|
||||
python3 - "$CHUNKS_DIR" "$OUTPUT_DIR" "$NAME" "$SPLIT_POINTS" <<'PYEOF'
|
||||
import json, sys, os, glob
|
||||
|
||||
echo "Done. Output: $CHUNKS_DIR/merged_raw.txt"
|
||||
chunks_dir = sys.argv[1]
|
||||
output_dir = sys.argv[2]
|
||||
name = sys.argv[3]
|
||||
split_points_str = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
|
||||
if split_points_str.strip():
|
||||
split_points = [float(x) for x in split_points_str.strip().split()]
|
||||
else:
|
||||
split_points = []
|
||||
offsets = [0.0] + split_points
|
||||
|
||||
chunk_files = sorted(glob.glob(os.path.join(chunks_dir, "chunk_*.json")))
|
||||
all_segments = []
|
||||
total_duration = 0
|
||||
|
||||
for idx, cf in enumerate(chunk_files):
|
||||
with open(cf) as f:
|
||||
data = json.load(f)
|
||||
offset = offsets[idx] if idx < len(offsets) else offsets[-1]
|
||||
for seg in data.get("segments", []):
|
||||
all_segments.append({
|
||||
"start": round(seg.get("start", 0) + offset, 2),
|
||||
"end": round(seg.get("end", 0) + offset, 2),
|
||||
"text": seg.get("text", "").strip(),
|
||||
})
|
||||
chunk_dur = data.get("duration", 0)
|
||||
total_duration = max(total_duration, offset + chunk_dur)
|
||||
|
||||
all_segments.sort(key=lambda s: s["start"])
|
||||
merged = {"segments": all_segments, "duration": total_duration}
|
||||
json_path = os.path.join(output_dir, f"{name}.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
|
||||
txt_path = os.path.join(output_dir, f"{name}.txt")
|
||||
with open(txt_path, "w") as f:
|
||||
for seg in all_segments:
|
||||
start = seg["start"]
|
||||
h, m, s = int(start // 3600), int((start % 3600) // 60), int(start % 60)
|
||||
f.write(f"[{h:02d}:{m:02d}:{s:02d}] {seg['text']}\n")
|
||||
|
||||
plain_path = os.path.join(output_dir, f"{name}_plain.txt")
|
||||
with open(plain_path, "w") as f:
|
||||
f.write(" ".join(seg["text"] for seg in all_segments))
|
||||
|
||||
print(f" {len(all_segments)} segments total")
|
||||
print(f" Written: {json_path}, {txt_path}, {plain_path}")
|
||||
PYEOF
|
||||
|
||||
echo "=== Done: $NAME ==="
|
||||
echo "=== Done: $NAME ==="
|
||||
Loading…
Add table
Add a link
Reference in a new issue