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
|
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
|
||||||
|
|
||||||
## Важные ограничения (читай первым!)
|
## Описание / Description
|
||||||
- **ОБЯЗАТЕЛЬНО установи переменные окружения** перед запуском транскрипции: `MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2`. Без этого скрипт упадёт на втором чанке с ошибкой "Intel oneMKL FATAL ERROR: Cannot load libctranslate2.so".
|
Этот навык транскрибирует аудиозаписи совещаний с помощью локально развёрнутого Whisper ASR, объединяет транскрипции из нескольких источников и создаёт структурированный отчёт о встрече в форматах Markdown и PDF.
|
||||||
- **НЕ пытайся запускать `faster-whisper`** как отдельную команду без этих переменных. Это Python-библиотека, вызывается через скрипты (`local_whisper.py`, `transcribe.sh`) с правильными параметрами.
|
|
||||||
- **НЕ изобретай свои собственные команды** для транскрипции или конвертации. Всё уже реализовано в готовых bash-скриптах.
|
|
||||||
- **Единственное, что ты должен сделать** – определить папку с аудио, убедиться, что переменные окружения установлены, и выполнить один скрипт.
|
|
||||||
- **Для генерации PDF:** Если `pandoc` не работает (нет шрифтов кириллицы), используй `weasyprint` (Python пакет).
|
|
||||||
- **Все процессы более 5 минут запускай в фоне** (`nohup ... &`) — execute_code имеет таймаут 5 минут.
|
|
||||||
|
|
||||||
## Структура навыка
|
## Триггеры / Triggers
|
||||||
- `scripts/overlay.sh` — обёртка запуска с MKL переменными
|
- "meeting report", "generate report", "transcribe meeting", "audio report", "отчёт о встрече", "сформируй отчёт", "расшифруй запись"
|
||||||
- `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` — шпаргалка по быстрому запуску
|
|
||||||
|
|
||||||
## Как именно запускать
|
## Инструкции / Instructions
|
||||||
|
|
||||||
### Шаг 0. Определи директорию встречи
|
### 1. Подготовка окружения / Environment setup
|
||||||
Пользователь укажет папку с аудиофайлами. По умолчанию:
|
Убедитесь, что установлены требуемые переменные окружения:
|
||||||
```
|
- `WHISPER_URL` – URL локального Whisper сервера (например, `http://localhost:8000`)
|
||||||
/app/hermes_data/meetings/YYYY-MM-DD/
|
- `WHISPER_API_KEY` – API ключ для доступа к Whisper серверу (Bearer токен)
|
||||||
```
|
|
||||||
Если пользователь указал другой путь – используй его.
|
|
||||||
|
|
||||||
### Шаг 1. Проверь наличие аудиофайлов
|
### 2. Запуск генерации отчёта / Generate a report
|
||||||
```bash
|
```bash
|
||||||
ls /app/hermes_data/meetings/2026-03-18/*.wav
|
bash scripts/generate_report.sh <meeting-date-dir>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Шаг 2. Установи переменные окружения (КРИТИЧНО!)
|
Пример:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export MKL_SERVICE_FORCE_INTEL=1
|
bash scripts/generate_report.sh 2026-04-29
|
||||||
export OMP_NUM_THREADS=2
|
|
||||||
```
|
|
||||||
Или передай перед python3:
|
|
||||||
```bash
|
|
||||||
MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 local_whisper.py ...
|
|
||||||
```
|
```
|
||||||
|
Ожидаемая структура:
|
||||||
|
|
||||||
### Шаг 3. Запусти транскрипцию
|
text
|
||||||
Для коротких файлов (<30 мин):
|
2026-04-29/
|
||||||
```bash
|
├── *.WAV # исходные аудиофайлы (Saramonic, Zoom H2n)
|
||||||
bash scripts/overlay.sh /app/hermes_data/meetings/2026-03-18
|
├── transcription/ # создаётся автоматически
|
||||||
```
|
├── merged.json # объединённые сегменты
|
||||||
|
├── merged_plain.txt # сплошной текст для LLM
|
||||||
|
└── report.pdf # финальный отчёт
|
||||||
|
3. Что делает generate_report.sh
|
||||||
|
Обнаруживает источники – Saramonic (*.WAV), Zoom H2n (SR*XY.WAV, SR*MS.WAV)
|
||||||
|
|
||||||
Для длинных файлов (>30 мин):
|
Конвертирует в MP3 и транскрибирует через Whisper (с поддержкой hotwords)
|
||||||
```bash
|
|
||||||
bash scripts/transcribe_chunked.sh /app/hermes_data/meetings/2026-03-18
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 4. Создай аналитический отчёт
|
Объединяет транскрипции из нескольких источников (основной + аудитория)
|
||||||
После получения `transcription/merged_plain.txt`:
|
|
||||||
1. Прочитай транскрипцию
|
|
||||||
2. Создай `report.md` со структурой:
|
|
||||||
- Краткая выжимка (1-2 абзаца)
|
|
||||||
- Ход совещания (подробно)
|
|
||||||
- Ключевые решения (список)
|
|
||||||
- Задачи и ответственные (срок действия)
|
|
||||||
- Итоги и следующие шаги
|
|
||||||
|
|
||||||
### Шаг 5. Генерация PDF
|
Конвертирует готовый report.md в PDF с помощью pandoc и xelatex
|
||||||
Если pandoc недоступен:
|
|
||||||
```bash
|
|
||||||
python3 scripts/generate_pdf.py report.md report.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
## Типичные ошибки
|
4. Ручное использование скриптов / Manual script usage
|
||||||
|
Транскрипция одного файла:
|
||||||
|
|
||||||
| Ошибка | Причина | Решение |
|
bash
|
||||||
|--------|---------|---------|
|
bash scripts/transcribe.sh <meeting_dir> <audio.WAV> <output_name>
|
||||||
| `Intel oneMKL FATAL ERROR` | Нет переменных окружения | Добавить `MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2` |
|
Объединение двух JSON транскрипций:
|
||||||
| `No WAV file found` | Нет аудиофайлов | Положить WAV в папку встречи |
|
|
||||||
| `weasyprint: command not found` | Нет пакета | `pip install weasyprint` |
|
|
||||||
| `ffmpeg: command not found` | Нет ffmpeg | `apt-get install ffmpeg` |
|
|
||||||
|
|
||||||
## Время обработки
|
bash
|
||||||
- Модель small: ~5-10 мин на 10 мин аудио
|
python3 scripts/merge_transcriptions.py <primary.json> <secondary.json> <output_dir>
|
||||||
- 1 час аудио = 30-60 минут обработки
|
Конкатенация WAV в MP3:
|
||||||
- Запускай в фоне: `nohup bash script.sh > /tmp/transcribe.log 2>&1 &`
|
|
||||||
- Следи: `tail -f /tmp/transcribe.log`
|
|
||||||
|
|
||||||
## Результат
|
bash
|
||||||
- `transcription/merged_plain.txt` — полная транскрипция
|
bash scripts/concat_wav.sh <output.mp3> <input1.WAV> <input2.WAV> ...
|
||||||
- `report.md` — аналитический отчёт Markdown
|
Чанковое транскрибирование (очень длинные записи):
|
||||||
- `report.pdf` — финальный PDF документ
|
|
||||||
|
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
|
# Concatenate multiple WAV/audio files into a single mp3 using ffmpeg concat demuxer
|
||||||
#
|
#
|
||||||
# Usage: ./concat_wav.sh <output.mp3> <input1.WAV> <input2.WAV> ...
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@ fi
|
||||||
OUTPUT="$1"
|
OUTPUT="$1"
|
||||||
shift
|
shift
|
||||||
|
|
||||||
|
# Build concat list file
|
||||||
LISTFILE=$(mktemp /tmp/ffmpeg_concat_XXXXXX.txt)
|
LISTFILE=$(mktemp /tmp/ffmpeg_concat_XXXXXX.txt)
|
||||||
trap "rm -f '$LISTFILE'" EXIT
|
trap "rm -f '$LISTFILE'" EXIT
|
||||||
|
|
||||||
|
|
@ -23,6 +24,8 @@ for f in "$@"; do
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Concatenating $# files -> $OUTPUT"
|
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
|
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)
|
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
|
#!/bin/bash
|
||||||
# generate_report.sh — Full pipeline for generating meeting report (without diagrams)
|
# generate_report.sh — Simplified pipeline: transcription + merge + PDF
|
||||||
# Usage: ./generate_report.sh /absolute/path/to/meeting_folder
|
# Usage: ./generate_report.sh <meeting-date-dir>
|
||||||
# Example: ./generate_report.sh /app/hermes_data/meetings/2026-04-15
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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
|
if [ $# -lt 1 ]; then
|
||||||
echo "Usage: $0 <absolute_path_to_meeting_folder>"
|
echo "Usage: $0 <meeting-date-dir>"
|
||||||
echo "Example: $0 /app/hermes_data/meetings/2026-04-15"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MEETING_DIR="$1"
|
MEETING_DIR="$SCRIPT_DIR/$1"
|
||||||
|
TRANSCRIPTION_DIR="$MEETING_DIR/transcription"
|
||||||
# If relative path provided, convert to absolute
|
DIAGRAMS_DIR="$MEETING_DIR/diagrams"
|
||||||
if [[ "$MEETING_DIR" != /* ]]; then
|
|
||||||
MEETING_DIR="$SCRIPT_DIR/$MEETING_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve absolute path
|
|
||||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
|
||||||
|
|
||||||
if [ ! -d "$MEETING_DIR" ]; then
|
if [ ! -d "$MEETING_DIR" ]; then
|
||||||
echo "Error: Meeting directory not found: $MEETING_DIR"
|
echo "Error: Meeting directory not found: $MEETING_DIR"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ============================================================
|
# ------------------------------------------------------------
|
||||||
# Step 1: Transcription (skip if already done)
|
# Step 1: Transcription (skip if already done)
|
||||||
# ============================================================
|
# ------------------------------------------------------------
|
||||||
if [ -d "$MEETING_DIR/transcription" ] && [ -f "$MEETING_DIR/transcription/plain_text.txt" ]; then
|
if [ -d "$TRANSCRIPTION_DIR" ] && [ -f "$TRANSCRIPTION_DIR/plain_text.txt" ]; then
|
||||||
echo "[1/2] Transcription already exists, skipping."
|
echo "[1/3] Transcription already exists, skipping."
|
||||||
else
|
else
|
||||||
echo "[1/2] Running transcription..."
|
echo "[1/3] Running transcription..."
|
||||||
bash "$SCRIPT_DIR/transcribe.sh" "$MEETING_DIR"
|
bash "$SCRIPT_DIR/transcribe.sh" "$MEETING_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ============================================================
|
# ------------------------------------------------------------
|
||||||
# Step 2: Generate PDF from markdown
|
# Step 2: Merge transcriptions (if both saramonic and h2n exist)
|
||||||
# ============================================================
|
# ------------------------------------------------------------
|
||||||
echo "[2/2] Generating PDF..."
|
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_MD="$MEETING_DIR/report.md"
|
||||||
REPORT_PDF="$MEETING_DIR/report.pdf"
|
REPORT_PDF="$MEETING_DIR/report.pdf"
|
||||||
|
|
||||||
if [ ! -f "$REPORT_MD" ]; then
|
if [ ! -f "$REPORT_MD" ]; then
|
||||||
echo " Error: report.md not found at $REPORT_MD"
|
echo " No report.md found. Agent must write this file first."
|
||||||
exit 1
|
echo " After writing the report, run: pandoc $REPORT_MD -o $REPORT_PDF ..."
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$MEETING_DIR"
|
cd "$MEETING_DIR"
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ Wildberries, Хабр
|
||||||
тимлид, календарный план, конгломерат, квиз
|
тимлид, календарный план, конгломерат, квиз
|
||||||
стартап, деплой, инфраструктура, безопасность
|
стартап, деплой, инфраструктура, безопасность
|
||||||
IT-льготы, GitHub, open source, VPS
|
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
|
#!/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 sys
|
||||||
import os
|
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 = []
|
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:
|
# Add source tag to primary segments
|
||||||
f.write('\n\n'.join(merged))
|
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__":
|
if __name__ == "__main__":
|
||||||
dir_path = sys.argv[1] if len(sys.argv) > 1 else "transcription"
|
main()
|
||||||
merge_transcriptions(dir_path)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
#!/bin/bash
|
||||||
# Transcribe audio recordings using local faster-whisper
|
# Transcribe audio recordings using local whisper server (with API key)
|
||||||
# Supports multiple sources: Zoom H2n (4ch WAV), Saramonic (mono WAV), etc.
|
# Usage: ./transcribe.sh <meeting_dir> [<file.WAV> <output_name>]
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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"
|
LANGUAGE="ru"
|
||||||
|
|
||||||
# Load hotwords
|
# Hotwords
|
||||||
HOTWORDS_FILE="$SCRIPT_DIR/hotwords.txt"
|
HOTWORDS_FILE="${HOTWORKS_PATH:-$SCRIPT_DIR/hotwords.txt}"
|
||||||
|
HOTWORDS=""
|
||||||
if [ -f "$HOTWORDS_FILE" ]; then
|
if [ -f "$HOTWORDS_FILE" ]; then
|
||||||
HOTWORDS=$(grep -v '^#' "$HOTWORDS_FILE" | grep -v '^$' | tr '\n' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
|
HOTWORDS=$(grep -v '^#' "$HOTWORDS_FILE" | grep -v '^$' | tr '\n' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')
|
||||||
echo "Loaded hotwords from $HOTWORDS_FILE"
|
echo "Loaded hotwords from $HOTWORDS_FILE"
|
||||||
else
|
|
||||||
HOTWORDS=""
|
|
||||||
echo "Warning: hotwords.txt not found, proceeding without hotwords"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------- argument parsing ----------
|
||||||
if [ $# -lt 1 ]; then
|
if [ $# -lt 1 ]; then
|
||||||
echo "Usage: $0 <absolute_meeting_dir> [<file.WAV> <output_name>]"
|
echo "Usage: $0 <meeting_dir> [<file.WAV> <output_name>]"
|
||||||
echo "Example: $0 /app/hermes_data/meetings/2026-02-18"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MEETING_DIR="$1"
|
MEETING_DIR="$1"
|
||||||
if [[ "$MEETING_DIR" != /* ]]; then
|
WORK_DIR="$(cd "$SCRIPT_DIR/$MEETING_DIR" && pwd)"
|
||||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
|
||||||
else
|
|
||||||
MEETING_DIR="$(realpath "$MEETING_DIR")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
WORK_DIR="$MEETING_DIR"
|
|
||||||
OUTPUT_DIR="$WORK_DIR/transcription"
|
OUTPUT_DIR="$WORK_DIR/transcription"
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
# Function: convert WAV(s) to mono mp3
|
|
||||||
convert_to_mp3() {
|
convert_to_mp3() {
|
||||||
local output_mp3="$1"
|
local output_mp3="$1"
|
||||||
shift
|
shift
|
||||||
local inputs=("$@")
|
local inputs=("$@")
|
||||||
|
|
||||||
if [ -f "$output_mp3" ]; then
|
if [ -f "$output_mp3" ]; then
|
||||||
echo " $output_mp3 already exists, skipping conversion"
|
echo " $output_mp3 already exists, skipping conversion"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ${#inputs[@]} -eq 1 ]; then
|
if [ ${#inputs[@]} -eq 1 ]; then
|
||||||
echo " Converting ${inputs[0]} -> $output_mp3"
|
echo " Converting ${inputs[0]} -> $output_mp3"
|
||||||
ffmpeg -y -i "${inputs[0]}" -ac 1 -ar 16000 -b:a 64k "$output_mp3" 2>/dev/null
|
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
|
ffmpeg -y -f concat -safe 0 -i "$listfile" -ac 1 -ar 16000 -b:a 64k "$output_mp3" 2>/dev/null
|
||||||
rm -f "$listfile"
|
rm -f "$listfile"
|
||||||
fi
|
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() {
|
transcribe_file() {
|
||||||
local mp3_file="$1"
|
local mp3_file="$1"
|
||||||
local name="$2"
|
local name="$2"
|
||||||
|
|
@ -84,21 +62,42 @@ transcribe_file() {
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check duration of mp3
|
echo " Transcribing $name..."
|
||||||
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)..."
|
|
||||||
local started
|
local started
|
||||||
started=$(date +%s)
|
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 ))
|
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"
|
echo " Done in ${elapsed}s"
|
||||||
|
|
||||||
# Extract plain text and timestamped text
|
# Extract plain text and timestamped text
|
||||||
|
|
@ -135,7 +134,7 @@ print(f" {len(segs)} segments, {len(plain)} chars")
|
||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# Manual mode: specific file
|
# ---------- manual mode (specific file) ----------
|
||||||
if [ $# -ge 3 ]; then
|
if [ $# -ge 3 ]; then
|
||||||
WAV_FILE="$WORK_DIR/$2"
|
WAV_FILE="$WORK_DIR/$2"
|
||||||
NAME="$3"
|
NAME="$3"
|
||||||
|
|
@ -148,14 +147,11 @@ if [ $# -ge 3 ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auto mode: detect and transcribe all sources
|
# ---------- auto mode ----------
|
||||||
echo "=== Auto-detecting audio sources in $WORK_DIR ==="
|
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_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)
|
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)
|
mapfile -t SARAMONIC_FILES < <(find "$WORK_DIR" -maxdepth 1 -name "*.WAV" ! -name "SR*" | sort)
|
||||||
|
|
||||||
SOURCES=()
|
SOURCES=()
|
||||||
|
|
@ -202,9 +198,3 @@ done
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Done! ==="
|
echo "=== Done! ==="
|
||||||
echo "Results in: $OUTPUT_DIR/"
|
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
|
#!/bin/bash
|
||||||
# Транскрипция с разбивкой на чанки для длинных аудио
|
# Transcribe long audio by splitting at silence boundaries
|
||||||
|
# Uses Whisper API with authentication
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MEETING_DIR="${1:-.}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
CHUNKS_DIR="$MEETING_DIR/transcription"
|
: ${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"
|
mkdir -p "$CHUNKS_DIR"
|
||||||
|
|
||||||
# Get audio file
|
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$MP3_FILE" | cut -d. -f1)
|
||||||
WAV_FILE=$(ls "$MEETING_DIR"/*.wav 2>/dev/null || ls "$MEETING_DIR"/*.WAV 2>/dev/null)
|
echo "=== Chunked transcription: $NAME ($DURATION s / $((DURATION/60))m) ==="
|
||||||
if [ -z "$WAV_FILE" ] || [ ! -f "$WAV_FILE" ]; then
|
|
||||||
echo "Error: No WAV file found"
|
# Find silence gaps
|
||||||
exit 1
|
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"
|
||||||
|
|
||||||
|
# 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])
|
||||||
|
|
||||||
|
with open(silences_file) as f:
|
||||||
|
silences = [float(line.strip()) for line in f if line.strip()]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
print(" ".join(f"{s:.2f}" for s in splits))
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
IFS=' ' read -ra POINTS <<< "$SPLIT_POINTS"
|
||||||
|
N_CHUNKS=$((${#POINTS[@]} + 1))
|
||||||
|
echo " Will create $N_CHUNKS chunks"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
echo " chunk_$(printf '%03d' $i): exists, skipping"
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Duration
|
if [ $i -lt ${#POINTS[@]} ]; then
|
||||||
DURATION=$(ffprobe -i "$WAV_FILE" -show_entries format=duration -v quiet -of csv="p=0")
|
END="${POINTS[$i]}"
|
||||||
|
DUR=$(python3 -c "print(f'{$END - $PREV:.2f}')")
|
||||||
echo "Audio duration: $DURATION seconds"
|
ffmpeg -y -i "$MP3_FILE" -ss "$PREV" -t "$DUR" -c copy "$CHUNK_FILE" 2>/dev/null
|
||||||
|
PREV="$END"
|
||||||
# Chunk settings
|
else
|
||||||
chunk_duration=600
|
ffmpeg -y -i "$MP3_FILE" -ss "$PREV" -c copy "$CHUNK_FILE" 2>/dev/null
|
||||||
offset=0
|
|
||||||
chunk_num=0
|
|
||||||
|
|
||||||
echo "Extracting chunks..."
|
|
||||||
|
|
||||||
while (( $(echo "$offset < $DURATION" | bc -l) )); do
|
|
||||||
chunk_file="$CHUNKS_DIR/chunk_${chunk_num}.wav"
|
|
||||||
echo "Extracting chunk $chunk_num at offset $offset..."
|
|
||||||
|
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
sleep 1
|
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
|
done
|
||||||
|
|
||||||
offset=$((offset + chunk_duration))
|
|
||||||
((chunk_num++))
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Transcribing $chunk_num chunks..."
|
|
||||||
|
|
||||||
# Transcribe each chunk
|
# Transcribe each chunk
|
||||||
for i in $(seq 0 $((chunk_num - 1))); do
|
echo "Transcribing chunks..."
|
||||||
chunk_file="$CHUNKS_DIR/chunk_${i}.wav"
|
for i in $(seq 0 $((N_CHUNKS - 1))); do
|
||||||
output_file="$CHUNKS_DIR/chunk_${i}.txt"
|
CHUNK_FILE="$CHUNKS_DIR/chunk_$(printf '%03d' $i).mp3"
|
||||||
|
CHUNK_JSON="$CHUNKS_DIR/chunk_$(printf '%03d' $i).json"
|
||||||
|
|
||||||
echo "Transcribing chunk $i..."
|
if [ -f "$CHUNK_JSON" ]; then
|
||||||
MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 "${BASH_SOURCE[0]%/*}/local_whisper.py" "$chunk_file" > "$output_file"
|
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
|
done
|
||||||
|
|
||||||
# Merge
|
# Merge chunks into final JSON
|
||||||
echo "Merging transcriptions..."
|
echo "Merging chunks..."
|
||||||
cat "$CHUNKS_DIR"/chunk_*.txt > "$CHUNKS_DIR/merged_raw.txt"
|
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