diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 4cf3c69..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -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! diff --git a/README.md b/README.md deleted file mode 100644 index d45688d..0000000 --- a/README.md +++ /dev/null @@ -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 & diff --git a/SKILL.md b/SKILL.md index df9b11d..252c43e 100644 --- a/SKILL.md +++ b/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 ``` -### Шаг 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 +Объединение двух 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 +Конкатенация WAV в MP3: -## Результат -- `transcription/merged_plain.txt` — полная транскрипция -- `report.md` — аналитический отчёт Markdown -- `report.pdf` — финальный PDF документ +bash +bash scripts/concat_wav.sh ... +Чанковое транскрибирование (очень длинные записи): + +bash +bash scripts/transcribe_chunked.sh +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 `. + Скрипт обнаружит уже существующий `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. \ No newline at end of file diff --git a/scripts/concat_wav.sh b/scripts/concat_wav.sh index 8ab8df6..5da70e5 100644 --- a/scripts/concat_wav.sh +++ b/scripts/concat_wav.sh @@ -2,7 +2,7 @@ # Concatenate multiple WAV/audio files into a single mp3 using ffmpeg concat demuxer # # Usage: ./concat_wav.sh ... -# 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,8 +24,10 @@ 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) SIZE=$(du -h "$OUTPUT" | cut -f1) -echo "Done: ${DUR}s ($(( DUR / 60 ))m$(( DUR % 60 ))s), $SIZE" \ No newline at end of file +echo "Done: ${DUR}s ($(( DUR / 60 ))m$(( DUR % 60 ))s), $SIZE" diff --git a/scripts/generate_pdf.py b/scripts/generate_pdf.py deleted file mode 100644 index 962de0b..0000000 --- a/scripts/generate_pdf.py +++ /dev/null @@ -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""" - - - - - - - {md_content.replace('## ', '

').replace('# ', '

')} - - - """ - - HTML(string=html_content).write_pdf(pdf_path) - -if __name__ == "__main__": - if len(sys.argv) < 3: - print("Usage: python3 generate_pdf.py ") - sys.exit(1) - - markdown_to_pdf(sys.argv[1], sys.argv[2]) - print(f"PDF created: {sys.argv[2]}") diff --git a/scripts/generate_report.sh b/scripts/generate_report.sh index ee42d3b..a2c9817 100644 --- a/scripts/generate_report.sh +++ b/scripts/generate_report.sh @@ -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 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 " - echo "Example: $0 /app/hermes_data/meetings/2026-04-15" + echo "Usage: $0 " 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" diff --git a/scripts/hotwords.txt b/scripts/hotwords.txt index 1f96698..4bc26cc 100644 --- a/scripts/hotwords.txt +++ b/scripts/hotwords.txt @@ -35,4 +35,4 @@ Wildberries, Хабр тимлид, календарный план, конгломерат, квиз стартап, деплой, инфраструктура, безопасность IT-льготы, GitHub, open source, VPS -петличка, Saramonic, диктофон, скрипт +петличка, Saramonic, диктофон diff --git a/scripts/local_whisper.py b/scripts/local_whisper.py deleted file mode 100644 index d5c7d53..0000000 --- a/scripts/local_whisper.py +++ /dev/null @@ -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 ") - sys.exit(1) - - segments = transcribe_audio(sys.argv[1]) - - for segment in segments: - print(segment.text) diff --git a/scripts/merge_transcriptions.py b/scripts/merge_transcriptions.py index 112df43..98e602d 100644 --- a/scripts/merge_transcriptions.py +++ b/scripts/merge_transcriptions.py @@ -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 [--offset SECONDS] + +Output: + /merged.json — combined segments with source tags + /merged.txt — timestamped text + /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)) - - print(f"Merged {len(txt_files)} files into {output_path}") + + # 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}") + if __name__ == "__main__": - dir_path = sys.argv[1] if len(sys.argv) > 1 else "transcription" - merge_transcriptions(dir_path) + main() diff --git a/scripts/overlay.sh b/scripts/overlay.sh deleted file mode 100644 index 156899d..0000000 --- a/scripts/overlay.sh +++ /dev/null @@ -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/" diff --git a/scripts/transcribe.sh b/scripts/transcribe.sh index 0d37f7d..0464825 100644 --- a/scripts/transcribe.sh +++ b/scripts/transcribe.sh @@ -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 [ ] 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 [ ]" - echo "Example: $0 /app/hermes_data/meetings/2026-02-18" + echo "Usage: $0 [ ]" 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=() @@ -201,10 +197,4 @@ 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 \ No newline at end of file +echo "Results in: $OUTPUT_DIR/" \ No newline at end of file diff --git a/scripts/transcribe_chunked.sh b/scripts/transcribe_chunked.sh index f8e68e0..01dcb6f 100644 --- a/scripts/transcribe_chunked.sh +++ b/scripts/transcribe_chunked.sh @@ -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..." - - # 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 +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 - sleep 1 - done - - offset=$((offset + chunk_duration)) - ((chunk_num++)) -done + echo " chunk_$(printf '%03d' $i): exists, skipping" + continue + fi -echo "Transcribing $chunk_num chunks..." + 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 # 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 chunk $i..." - MKL_SERVICE_FORCE_INTEL=1 OMP_NUM_THREADS=2 python3 "${BASH_SOURCE[0]%/*}/local_whisper.py" "$chunk_file" > "$output_file" +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" + + 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 ===" \ No newline at end of file