From 4cfd909cc56efd8d8a5d85a9fe130762f95bbf52 Mon Sep 17 00:00:00 2001 From: Stream Date: Thu, 30 Apr 2026 02:10:01 +0300 Subject: [PATCH] auto-sync: 2026-04-30 02:10:01 --- tasks/karaoke/karaoke.py | 8 ++- tasks/karaoke/render.py | 84 +++++++++++++++++++++++--- tasks/karaoke/transcribe.py | 116 ++++++++++++++++++++++++++++++++++-- 3 files changed, 194 insertions(+), 14 deletions(-) diff --git a/tasks/karaoke/karaoke.py b/tasks/karaoke/karaoke.py index 52c8ede..c7fbfaf 100644 --- a/tasks/karaoke/karaoke.py +++ b/tasks/karaoke/karaoke.py @@ -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("❌ Не удалось получить сегменты текста!") diff --git a/tasks/karaoke/render.py b/tasks/karaoke/render.py index 2c44f3f..77a7183 100644 --- a/tasks/karaoke/render.py +++ b/tasks/karaoke/render.py @@ -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) — показываем прогресс-бар diff --git a/tasks/karaoke/transcribe.py b/tasks/karaoke/transcribe.py index 4c0f427..0b81daa 100644 --- a/tasks/karaoke/transcribe.py +++ b/tasks/karaoke/transcribe.py @@ -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)