Add initial project structure: pipeline, docs, config, profiles
This commit is contained in:
parent
4214d689dd
commit
5a5d1fa960
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Модели и кэш
|
||||||
|
models/
|
||||||
|
*.bin
|
||||||
|
*.pt
|
||||||
|
*.pth
|
||||||
|
*.onnx
|
||||||
|
checkpoints/
|
||||||
|
|
||||||
|
# Временные и выходные файлы
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
output/
|
||||||
|
*.wav
|
||||||
|
*.mp3
|
||||||
|
*.m4a
|
||||||
|
*.ogg
|
||||||
|
*.flac
|
||||||
|
*.docx
|
||||||
|
*.md
|
||||||
|
*.txt
|
||||||
|
!.gitkeep
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
48
config.yaml
Normal file
48
config.yaml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Конфигурация пайплайна транскрибации совещаний
|
||||||
|
|
||||||
|
# Профили оборудования
|
||||||
|
profiles:
|
||||||
|
# Текущий: MacBook Air M4, 16GB RAM
|
||||||
|
mac_m4:
|
||||||
|
device: cpu # MPS на Mac может быть медленнее/багован; CPU + int8 стабильнее
|
||||||
|
compute_type: int8
|
||||||
|
batch_size: 1
|
||||||
|
model: large-v3
|
||||||
|
language: ru
|
||||||
|
diarize: true
|
||||||
|
|
||||||
|
# Будущий: GPU с 8GB VRAM
|
||||||
|
gpu_8gb:
|
||||||
|
device: cuda
|
||||||
|
compute_type: float16 # или int8 если не хватает памяти
|
||||||
|
batch_size: 1 # large-v3 + alignment + diarization в ~8GB
|
||||||
|
model: large-v3
|
||||||
|
language: ru
|
||||||
|
diarize: true
|
||||||
|
|
||||||
|
# Универсальный CPU (без GPU)
|
||||||
|
cpu_best:
|
||||||
|
device: cpu
|
||||||
|
compute_type: int8
|
||||||
|
batch_size: 1
|
||||||
|
model: large-v3
|
||||||
|
language: ru
|
||||||
|
diarize: true
|
||||||
|
|
||||||
|
# Активный профиль (можно переопределить через CLI: --profile gpu_8gb)
|
||||||
|
active_profile: mac_m4
|
||||||
|
|
||||||
|
# Настройки диаризации
|
||||||
|
hf_token: null # HuggingFace токен для pyannote. Установите через env: HF_TOKEN
|
||||||
|
|
||||||
|
# Настройки выходного документа
|
||||||
|
output:
|
||||||
|
format: docx # docx | md | txt
|
||||||
|
include_timestamps: true
|
||||||
|
speaker_label_style: name # name | id | none
|
||||||
|
paragraph_pause_sec: 2.0 # новый абзац, если пауза > N секунд
|
||||||
|
|
||||||
|
# Пути
|
||||||
|
paths:
|
||||||
|
output_dir: ./output
|
||||||
|
temp_dir: ./tmp
|
||||||
1
examples/.gitkeep
Normal file
1
examples/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Placeholder for example audio files
|
||||||
100
run.py
Normal file
100
run.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""CLI entrypoint для транскрибации совещаний."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.config import get_profile, load_config
|
||||||
|
from src.document import build_document
|
||||||
|
from src.pipeline import run_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_device(preferred: str) -> str:
|
||||||
|
"""Определяет доступное устройство."""
|
||||||
|
import torch
|
||||||
|
if preferred == "cuda" and torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
if preferred == "mps" and torch.backends.mps.is_available():
|
||||||
|
return "mps"
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Транскрибация совещаний с диаризацией и таймкодами."
|
||||||
|
)
|
||||||
|
parser.add_argument("--input", "-i", required=True, help="Путь к аудиофайлу")
|
||||||
|
parser.add_argument("--output", "-o", default=None, help="Путь к выходному файлу (docx/md/txt)")
|
||||||
|
parser.add_argument("--profile", "-p", default=None, help="Профиль конфигурации (mac_m4, gpu_8gb, cpu_best)")
|
||||||
|
parser.add_argument("--config", "-c", default=None, help="Путь к config.yaml")
|
||||||
|
parser.add_argument("--device", "-d", default=None, help="Принудительно: cpu/cuda/mps")
|
||||||
|
parser.add_argument("--model", "-m", default=None, help="Принудительно: tiny/base/small/medium/large-v3")
|
||||||
|
parser.add_argument("--language", "-l", default=None, help="Язык (ru, en, ...)")
|
||||||
|
parser.add_argument("--format", "-f", default=None, help="Формат выхода: docx, md, txt")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not Path(args.input).exists():
|
||||||
|
print(f"Ошибка: файл не найден: {args.input}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Загрузка конфига
|
||||||
|
config = load_config(args.config)
|
||||||
|
profile = get_profile(config, args.profile)
|
||||||
|
|
||||||
|
# Переопределения из CLI
|
||||||
|
if args.device:
|
||||||
|
profile["device"] = resolve_device(args.device)
|
||||||
|
else:
|
||||||
|
profile["device"] = resolve_device(profile.get("device", "cpu"))
|
||||||
|
if args.model:
|
||||||
|
profile["model"] = args.model
|
||||||
|
if args.language:
|
||||||
|
profile["language"] = args.language
|
||||||
|
|
||||||
|
output_cfg = config.get("output", {})
|
||||||
|
fmt = args.format or output_cfg.get("format", "docx")
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
output_path = args.output
|
||||||
|
else:
|
||||||
|
stem = Path(args.input).stem
|
||||||
|
output_dir = Path(config.get("paths", {}).get("output_dir", "./output"))
|
||||||
|
output_path = str(output_dir / f"{stem}.{fmt}")
|
||||||
|
|
||||||
|
# Проверка HF токена
|
||||||
|
hf_token = os.environ.get("HF_TOKEN") or config.get("hf_token")
|
||||||
|
if profile.get("diarize", True) and not hf_token:
|
||||||
|
print(
|
||||||
|
"Ошибка: для диаризации нужен HuggingFace токен.\n"
|
||||||
|
"Установите env HF_TOKEN или укажите hf_token в config.yaml",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Профиль: {args.profile or config.get('active_profile')}")
|
||||||
|
print(f"Устройство: {profile['device']}")
|
||||||
|
print(f"Модель: {profile['model']}")
|
||||||
|
print(f"Язык: {profile['language']}")
|
||||||
|
print(f"Вход: {args.input}")
|
||||||
|
print(f"Выход: {output_path}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
# Запуск пайплайна
|
||||||
|
result = run_pipeline(
|
||||||
|
audio_path=args.input,
|
||||||
|
profile_name=args.profile,
|
||||||
|
config_path=args.config,
|
||||||
|
output_path=output_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Генерация документа
|
||||||
|
build_document(result["segments"], output_path, config)
|
||||||
|
|
||||||
|
print("-" * 40)
|
||||||
|
print(f"Готово! Сохранено: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
33
src/config.py
Normal file
33
src/config.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""Загрузка и управление конфигурацией."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path | None = None) -> Dict[str, Any]:
|
||||||
|
"""Загружает YAML-конфиг."""
|
||||||
|
config_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(config: Dict[str, Any], profile_name: str | None = None) -> Dict[str, Any]:
|
||||||
|
"""Возвращает слитый профиль (base + выбранный)."""
|
||||||
|
active = profile_name or config.get("active_profile", "cpu_best")
|
||||||
|
profiles = config.get("profiles", {})
|
||||||
|
if active not in profiles:
|
||||||
|
raise ValueError(f"Профиль '{active}' не найден. Доступные: {list(profiles.keys())}")
|
||||||
|
return profiles[active]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hf_token(config: Dict[str, Any]) -> str | None:
|
||||||
|
"""Возвращает HF токен: из конфига или env HF_TOKEN."""
|
||||||
|
token = config.get("hf_token")
|
||||||
|
if not token:
|
||||||
|
token = os.environ.get("HF_TOKEN")
|
||||||
|
return token
|
||||||
182
src/document.py
Normal file
182
src/document.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"""Генерация выходных документов (docx, md, txt)."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
"""Форматирует секунды в [HH:MM:SS.mmm]."""
|
||||||
|
td = timedelta(seconds=seconds)
|
||||||
|
# td может быть > 1 день, но нам нужно HH:MM:SS
|
||||||
|
total_seconds = int(td.total_seconds())
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
millis = int((seconds - total_seconds) * 1000)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_docx(segments: List[Dict[str, Any]], output_path: str, config: Dict[str, Any]) -> str:
|
||||||
|
"""Собирает .docx с протоколом совещания."""
|
||||||
|
output_cfg = config.get("output", {})
|
||||||
|
include_ts = output_cfg.get("include_timestamps", True)
|
||||||
|
pause_sec = output_cfg.get("paragraph_pause_sec", 2.0)
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
doc.add_heading("Протокол совещания", level=0)
|
||||||
|
|
||||||
|
# Группируем по спикерам с учётом пауз
|
||||||
|
current_speaker = None
|
||||||
|
current_texts: List[str] = []
|
||||||
|
current_start: float = 0.0
|
||||||
|
|
||||||
|
def flush_paragraph():
|
||||||
|
nonlocal current_speaker, current_texts, current_start
|
||||||
|
if not current_texts:
|
||||||
|
return
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
if include_ts:
|
||||||
|
ts = format_time(current_start)
|
||||||
|
run_ts = p.add_run(f"[{ts}] ")
|
||||||
|
run_ts.bold = True
|
||||||
|
run_ts.font.size = Pt(10)
|
||||||
|
|
||||||
|
run_spk = p.add_run(f"{current_speaker}:\n")
|
||||||
|
run_spk.bold = True
|
||||||
|
run_spk.font.size = Pt(11)
|
||||||
|
|
||||||
|
p.add_run(" ".join(current_texts))
|
||||||
|
current_texts = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
speaker = seg["speaker"]
|
||||||
|
text = seg["text"]
|
||||||
|
start = seg["start"]
|
||||||
|
end = seg["end"]
|
||||||
|
|
||||||
|
# Новый абзац, если сменился спикер или пауза большая
|
||||||
|
prev_end = segments[i - 1]["end"] if i > 0 else start
|
||||||
|
if speaker != current_speaker or (start - prev_end) > pause_sec:
|
||||||
|
flush_paragraph()
|
||||||
|
current_speaker = speaker
|
||||||
|
current_start = start
|
||||||
|
|
||||||
|
if not current_texts:
|
||||||
|
current_start = start
|
||||||
|
current_texts.append(text)
|
||||||
|
|
||||||
|
flush_paragraph()
|
||||||
|
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
doc.save(output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_md(segments: List[Dict[str, Any]], output_path: str, config: Dict[str, Any]) -> str:
|
||||||
|
"""Собирает Markdown."""
|
||||||
|
output_cfg = config.get("output", {})
|
||||||
|
include_ts = output_cfg.get("include_timestamps", True)
|
||||||
|
pause_sec = output_cfg.get("paragraph_pause_sec", 2.0)
|
||||||
|
|
||||||
|
lines = ["# Протокол совещания\n", ""]
|
||||||
|
|
||||||
|
current_speaker = None
|
||||||
|
current_texts: List[str] = []
|
||||||
|
current_start: float = 0.0
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
nonlocal current_speaker, current_texts, current_start
|
||||||
|
if not current_texts:
|
||||||
|
return
|
||||||
|
ts = format_time(current_start)
|
||||||
|
if include_ts:
|
||||||
|
lines.append(f"**[{ts}] {current_speaker}:**")
|
||||||
|
else:
|
||||||
|
lines.append(f"**{current_speaker}:**")
|
||||||
|
lines.append(" ".join(current_texts))
|
||||||
|
lines.append("")
|
||||||
|
current_texts = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
speaker = seg["speaker"]
|
||||||
|
text = seg["text"]
|
||||||
|
start = seg["start"]
|
||||||
|
prev_end = segments[i - 1]["end"] if i > 0 else start
|
||||||
|
if speaker != current_speaker or (start - prev_end) > pause_sec:
|
||||||
|
flush()
|
||||||
|
current_speaker = speaker
|
||||||
|
current_start = start
|
||||||
|
if not current_texts:
|
||||||
|
current_start = start
|
||||||
|
current_texts.append(text)
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(lines))
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_txt(segments: List[Dict[str, Any]], output_path: str, config: Dict[str, Any]) -> str:
|
||||||
|
"""Собирает plain text."""
|
||||||
|
output_cfg = config.get("output", {})
|
||||||
|
include_ts = output_cfg.get("include_timestamps", True)
|
||||||
|
pause_sec = output_cfg.get("paragraph_pause_sec", 2.0)
|
||||||
|
|
||||||
|
lines: List[str] = ["Протокол совещания\n", ""]
|
||||||
|
|
||||||
|
current_speaker = None
|
||||||
|
current_texts: List[str] = []
|
||||||
|
current_start: float = 0.0
|
||||||
|
|
||||||
|
def flush():
|
||||||
|
nonlocal current_speaker, current_texts, current_start
|
||||||
|
if not current_texts:
|
||||||
|
return
|
||||||
|
ts = format_time(current_start)
|
||||||
|
if include_ts:
|
||||||
|
lines.append(f"[{ts}] {current_speaker}:")
|
||||||
|
else:
|
||||||
|
lines.append(f"{current_speaker}:")
|
||||||
|
lines.append(" ".join(current_texts))
|
||||||
|
lines.append("")
|
||||||
|
current_texts = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(segments):
|
||||||
|
speaker = seg["speaker"]
|
||||||
|
text = seg["text"]
|
||||||
|
start = seg["start"]
|
||||||
|
prev_end = segments[i - 1]["end"] if i > 0 else start
|
||||||
|
if speaker != current_speaker or (start - prev_end) > pause_sec:
|
||||||
|
flush()
|
||||||
|
current_speaker = speaker
|
||||||
|
current_start = start
|
||||||
|
if not current_texts:
|
||||||
|
current_start = start
|
||||||
|
current_texts.append(text)
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(lines))
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_document(segments: List[Dict[str, Any]], output_path: str, config: Dict[str, Any]) -> str:
|
||||||
|
"""Роутер по формату."""
|
||||||
|
fmt = Path(output_path).suffix.lower().lstrip(".")
|
||||||
|
if fmt == "docx":
|
||||||
|
return build_docx(segments, output_path, config)
|
||||||
|
elif fmt == "md":
|
||||||
|
return build_md(segments, output_path, config)
|
||||||
|
elif fmt == "txt":
|
||||||
|
return build_txt(segments, output_path, config)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Неподдерживаемый формат: {fmt}. Используйте: docx, md, txt")
|
||||||
101
src/pipeline.py
Normal file
101
src/pipeline.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Основной пайплайн: WhisperX → структурированный результат."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import whisperx
|
||||||
|
from whisperx.diarize import DiarizationPipeline
|
||||||
|
|
||||||
|
from src.config import get_profile, load_config, resolve_hf_token
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(
|
||||||
|
audio_path: str,
|
||||||
|
profile_name: Optional[str] = None,
|
||||||
|
config_path: Optional[str] = None,
|
||||||
|
output_path: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Запускает полный пайплайн транскрибации.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с сегментами (speaker, text, start, end).
|
||||||
|
"""
|
||||||
|
config = load_config(config_path)
|
||||||
|
profile = get_profile(config, profile_name)
|
||||||
|
|
||||||
|
device = profile["device"]
|
||||||
|
compute_type = profile["compute_type"]
|
||||||
|
batch_size = profile["batch_size"]
|
||||||
|
model_name = profile["model"]
|
||||||
|
language = profile["language"]
|
||||||
|
do_diarize = profile["diarize"]
|
||||||
|
|
||||||
|
hf_token = resolve_hf_token(config)
|
||||||
|
|
||||||
|
# 1. Загрузка аудио
|
||||||
|
audio = whisperx.load_audio(audio_path)
|
||||||
|
|
||||||
|
# 2. Транскрибация (ASR)
|
||||||
|
print(f"[Pipeline] Загрузка модели Whisper: {model_name} ({device}, {compute_type})")
|
||||||
|
model = whisperx.load_model(model_name, device, compute_type=compute_type)
|
||||||
|
print("[Pipeline] Транскрибация...")
|
||||||
|
result = model.transcribe(audio, batch_size=batch_size, language=language)
|
||||||
|
del model
|
||||||
|
import gc
|
||||||
|
import torch
|
||||||
|
gc.collect()
|
||||||
|
if device == "cuda":
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
# 3. Alignment (точные таймкоды слов)
|
||||||
|
print("[Pipeline] Загрузка alignment-модели...")
|
||||||
|
model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
|
||||||
|
print("[Pipeline] Выравнивание таймкодов...")
|
||||||
|
result = whisperx.align(result["segments"], model_a, metadata, audio, device, return_char_alignments=False)
|
||||||
|
del model_a
|
||||||
|
gc.collect()
|
||||||
|
if device == "cuda":
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
# 4. Диаризация (кто говорил)
|
||||||
|
if do_diarize:
|
||||||
|
if not hf_token:
|
||||||
|
raise RuntimeError("HF_TOKEN не задан. Укажите в config.yaml или env HF_TOKEN.")
|
||||||
|
print("[Pipeline] Загрузка модели диаризации...")
|
||||||
|
diarize_model = DiarizationPipeline(token=hf_token, device=device)
|
||||||
|
print("[Pipeline] Диаризация...")
|
||||||
|
diarize_segments = diarize_model(audio)
|
||||||
|
result = whisperx.assign_word_speakers(diarize_segments, result)
|
||||||
|
del diarize_model
|
||||||
|
gc.collect()
|
||||||
|
if device == "cuda":
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
|
||||||
|
# 5. Форматирование результата
|
||||||
|
segments = format_segments(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"segments": segments,
|
||||||
|
"language": result.get("language", language),
|
||||||
|
"output_path": output_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_segments(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Преобразует результат whisperx в плоский список сегментов."""
|
||||||
|
segments = []
|
||||||
|
for seg in result.get("segments", []):
|
||||||
|
speaker = seg.get("speaker", "UNKNOWN")
|
||||||
|
text = seg.get("text", "").strip()
|
||||||
|
start = seg.get("start", 0.0)
|
||||||
|
end = seg.get("end", 0.0)
|
||||||
|
if text:
|
||||||
|
segments.append({
|
||||||
|
"speaker": speaker,
|
||||||
|
"text": text,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
})
|
||||||
|
return segments
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Loading…
Reference in New Issue
Block a user