auto-sync: 2026-04-30 02:10:01
This commit is contained in:
@@ -71,6 +71,8 @@ def main():
|
||||
help="Устройство для Whisper (по умолчанию: cpu)")
|
||||
parser.add_argument("--language", default=None,
|
||||
help="Код языка (напр. ru, en) для Whisper (None = авто)")
|
||||
parser.add_argument("--no-api", action="store_true",
|
||||
help="Не использовать OpenAI API, только локальный faster-whisper")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -102,10 +104,12 @@ def main():
|
||||
print(f" Используем готовый текст: {args.text} ({ext})")
|
||||
segments = transcribe(audio_path, text_path=args.text,
|
||||
model_size=model_size,
|
||||
device=args.device, language=args.language)
|
||||
device=args.device, language=args.language,
|
||||
use_api=not args.no_api)
|
||||
else:
|
||||
segments = transcribe(audio_path, model_size=model_size,
|
||||
device=args.device, language=args.language)
|
||||
device=args.device, language=args.language,
|
||||
use_api=not args.no_api)
|
||||
|
||||
if not segments:
|
||||
print("❌ Не удалось получить сегменты текста!")
|
||||
|
||||
@@ -82,6 +82,69 @@ def _draw_text_centered(image: Image.Image, text: str,
|
||||
image.paste(overlay.convert("RGB"), (0, 0), overlay)
|
||||
|
||||
|
||||
def draw_karaoke_line(frame: Image.Image, text: str, words: list,
|
||||
t: float, font, y_ratio: float = 0.82):
|
||||
"""Рисует karaoke-строку: каждое слово своим цветом по таймингам.
|
||||
|
||||
Слова до текущего момента → жёлтые (255, 220, 0)
|
||||
Текущее слово → жёлтое + glow (чуть крупнее, ярче)
|
||||
Слова после → белые (255, 255, 255)
|
||||
"""
|
||||
if not words:
|
||||
# Fallback: нет word-timestamps — рисуем всю строку жёлтым
|
||||
_draw_text_centered(frame, text, font, font, True, 255, y_ratio=y_ratio, alpha=255)
|
||||
return
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Измеряем общую ширину
|
||||
total_width = sum(draw.textlength(w["word"] + " ", font=font) for w in words)
|
||||
x = (WIDTH - total_width) // 2
|
||||
y = int(HEIGHT * y_ratio)
|
||||
|
||||
COLOR_DONE = (255, 220, 0, 255) # уже пропето — жёлтый
|
||||
COLOR_WHITE = (255, 255, 255, 200) # ещё не пропето — белый
|
||||
COLOR_SHADOW = (0, 0, 0, 180)
|
||||
|
||||
overlay = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
|
||||
od = ImageDraw.Draw(overlay)
|
||||
|
||||
# Глоу-шрифт (чуть крупнее для текущего слова)
|
||||
try:
|
||||
glow_font = ImageFont.truetype(FONT_ACTIVE, FONT_SIZE + 4)
|
||||
except Exception:
|
||||
glow_font = font
|
||||
|
||||
for w in words:
|
||||
word_text = w["word"] + " "
|
||||
ww = draw.textlength(word_text, font=font)
|
||||
|
||||
if t >= w["end"]:
|
||||
color = COLOR_DONE
|
||||
use_font = font
|
||||
elif t >= w["start"]:
|
||||
# Текущее слово — жёлтый + glow
|
||||
color = (255, 235, 50, 255) # чуть ярче
|
||||
use_font = glow_font
|
||||
# Glow: рисуем ещё раз с небольшим сдвигом и полупрозрачностью
|
||||
od.text((x - 1, y - 1), word_text, font=glow_font, fill=(255, 220, 0, 80))
|
||||
od.text((x + 1, y + 1), word_text, font=glow_font, fill=(255, 220, 0, 80))
|
||||
else:
|
||||
color = COLOR_WHITE
|
||||
use_font = font
|
||||
|
||||
# Тень
|
||||
od.text((x + 2, y + 2), word_text, font=use_font, fill=COLOR_SHADOW)
|
||||
# Основной текст
|
||||
od.text((x, y), word_text, font=use_font, fill=color)
|
||||
x = int(x + ww)
|
||||
|
||||
if frame.mode == "RGBA":
|
||||
frame.alpha_composite(overlay)
|
||||
else:
|
||||
frame.paste(overlay.convert("RGB"), (0, 0), overlay)
|
||||
|
||||
|
||||
def draw_text_with_alpha(image: Image.Image, text: str,
|
||||
font_active, font_inactive,
|
||||
alpha: int = 255, active: bool = True,
|
||||
@@ -308,15 +371,20 @@ def render_with_bg(segments: list[dict], audio_path: str, bg_video: str,
|
||||
gap = (next_seg["start"] - t) if next_seg else 999
|
||||
|
||||
if active_seg:
|
||||
# Активный сегмент — рисуем с fade in/out
|
||||
frames_from_start = int((t - active_seg["start"]) * fps)
|
||||
fade_alpha = min(255, int(255 * frames_from_start / max(FADE_FRAMES, 1)))
|
||||
frames_to_end = int((active_seg["end"] - t) * fps)
|
||||
fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1)))
|
||||
# Активный сегмент — karaoke-эффект если есть word-timestamps
|
||||
has_words = isinstance(active_seg.get("words"), list) and len(active_seg.get("words", [])) > 0
|
||||
if has_words:
|
||||
draw_karaoke_line(frame, active_seg["text"], active_seg["words"], t, font_active)
|
||||
else:
|
||||
# Fallback без word-timestamps — старый fade in/out
|
||||
frames_from_start = int((t - active_seg["start"]) * fps)
|
||||
fade_alpha = min(255, int(255 * frames_from_start / max(FADE_FRAMES, 1)))
|
||||
frames_to_end = int((active_seg["end"] - t) * fps)
|
||||
fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1)))
|
||||
|
||||
_draw_text_centered(frame, active_seg["text"],
|
||||
font_active, font_inactive,
|
||||
True, max(fade_alpha, 128))
|
||||
_draw_text_centered(frame, active_seg["text"],
|
||||
font_active, font_inactive,
|
||||
True, max(fade_alpha, 128))
|
||||
|
||||
elif gap > 20 and next_seg:
|
||||
# Длинная пауза (>20s) — показываем прогресс-бар
|
||||
|
||||
@@ -14,11 +14,95 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whisper транскрипция
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI Whisper API (облако)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def transcribe_openai_api(audio_path: str, language: str = "ru") -> list[dict]:
|
||||
"""Транскрипция через OpenAI Whisper API. Возвращает сегменты с word-timestamps."""
|
||||
import httpx
|
||||
|
||||
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY не задан в окружении / ~/.openclaw/.env")
|
||||
|
||||
print("[transcribe] Отправляем аудио в OpenAI Whisper API…")
|
||||
|
||||
with open(audio_path, "rb") as f:
|
||||
resp = httpx.post(
|
||||
"https://api.openai.com/v1/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
data={
|
||||
"model": "whisper-1",
|
||||
"response_format": "verbose_json",
|
||||
"language": language,
|
||||
"timestamp_granularities[]": ["word", "segment"],
|
||||
},
|
||||
files={"file": f},
|
||||
timeout=300,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Собираем сегменты с word-timestamps
|
||||
results = []
|
||||
word_list = data.get("words", []) # плоский список слов с таймингами
|
||||
segments = data.get("segments", [])
|
||||
|
||||
if word_list and segments:
|
||||
# Распределяем слова по сегментам
|
||||
seg_offset = 0
|
||||
for seg in segments:
|
||||
seg_start = seg["start"]
|
||||
seg_end = seg["end"]
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
# Собираем слова, попадающие в этот сегмент
|
||||
seg_words = []
|
||||
for w in word_list:
|
||||
ws = w["start"]
|
||||
we = w["end"]
|
||||
ww = w.get("word", "")
|
||||
# Слово принадлежит сегменту, если его начало попадает в диапазон
|
||||
if ws >= seg_start - 0.05 and ws <= seg_end + 0.05:
|
||||
seg_words.append({
|
||||
"word": ww,
|
||||
"start": round(ws, 2),
|
||||
"end": round(we, 2),
|
||||
})
|
||||
results.append({
|
||||
"start": round(seg_start, 2),
|
||||
"end": round(seg_end, 2),
|
||||
"text": seg_text,
|
||||
"words": seg_words,
|
||||
})
|
||||
elif segments:
|
||||
for seg in segments:
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
results.append({
|
||||
"start": round(seg["start"], 2),
|
||||
"end": round(seg["end"], 2),
|
||||
"text": seg_text,
|
||||
})
|
||||
|
||||
print(f"[transcribe] OpenAI API вернул {len(results)} сегментов")
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whisper транскрипция (локальная)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def transcribe_whisper(audio_path: str, model_size: str = "base",
|
||||
language: Optional[str] = None,
|
||||
device: str = "cpu") -> list[dict]:
|
||||
@@ -39,11 +123,23 @@ def transcribe_whisper(audio_path: str, model_size: str = "base",
|
||||
text = seg.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
results.append({
|
||||
# Собираем word-timestamps если есть
|
||||
words = []
|
||||
if hasattr(seg, 'words') and seg.words:
|
||||
for w in seg.words:
|
||||
words.append({
|
||||
"word": w.word,
|
||||
"start": round(w.start, 2),
|
||||
"end": round(w.end, 2),
|
||||
})
|
||||
entry = {
|
||||
"start": round(seg.start, 2),
|
||||
"end": round(seg.end, 2),
|
||||
"text": text,
|
||||
})
|
||||
}
|
||||
if words:
|
||||
entry["words"] = words
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
@@ -244,7 +340,8 @@ def _align_by_segments(txt_lines: list[dict], whisper_segs: list[dict]) -> list[
|
||||
|
||||
def transcribe(audio_path: str, text_path: Optional[str] = None,
|
||||
model_size: str = "base", device: str = "cpu",
|
||||
language: Optional[str] = None) -> list[dict]:
|
||||
language: Optional[str] = None,
|
||||
use_api: bool = True) -> list[dict]:
|
||||
"""
|
||||
Универсальная функция транскрипции.
|
||||
|
||||
@@ -254,8 +351,9 @@ def transcribe(audio_path: str, text_path: Optional[str] = None,
|
||||
model_size — размер Whisper-модели (tiny, base, small, medium, large)
|
||||
device — "cpu" или "cuda"
|
||||
language — код языка (напр. "ru", "en") или None для авто
|
||||
use_api — использовать OpenAI API (по умолч. True)
|
||||
|
||||
Возвращает: [{start, end, text}, ...]
|
||||
Возвращает: [{start, end, text, words?}, ...]
|
||||
"""
|
||||
if text_path:
|
||||
ext = Path(text_path).suffix.lower()
|
||||
@@ -269,5 +367,15 @@ def transcribe(audio_path: str, text_path: Optional[str] = None,
|
||||
print(f"[transcribe] Неподдерживаемый формат: {text_path}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Проверяем, можно ли использовать OpenAI API
|
||||
if use_api:
|
||||
load_dotenv(os.path.expanduser("~/.openclaw/.env"))
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if api_key:
|
||||
try:
|
||||
return transcribe_openai_api(audio_path, language or "ru")
|
||||
except Exception as e:
|
||||
print(f"[transcribe] OpenAI API не удался ({e}), fallback на faster-whisper")
|
||||
|
||||
print(f"[transcribe] Запускаем Whisper ({model_size}, {device})…")
|
||||
return transcribe_whisper(audio_path, model_size, language, device)
|
||||
|
||||
Reference in New Issue
Block a user