auto-sync: 2026-04-30 02:10:01
This commit is contained in:
@@ -71,6 +71,8 @@ def main():
|
|||||||
help="Устройство для Whisper (по умолчанию: cpu)")
|
help="Устройство для Whisper (по умолчанию: cpu)")
|
||||||
parser.add_argument("--language", default=None,
|
parser.add_argument("--language", default=None,
|
||||||
help="Код языка (напр. ru, en) для Whisper (None = авто)")
|
help="Код языка (напр. ru, en) для Whisper (None = авто)")
|
||||||
|
parser.add_argument("--no-api", action="store_true",
|
||||||
|
help="Не использовать OpenAI API, только локальный faster-whisper")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -102,10 +104,12 @@ def main():
|
|||||||
print(f" Используем готовый текст: {args.text} ({ext})")
|
print(f" Используем готовый текст: {args.text} ({ext})")
|
||||||
segments = transcribe(audio_path, text_path=args.text,
|
segments = transcribe(audio_path, text_path=args.text,
|
||||||
model_size=model_size,
|
model_size=model_size,
|
||||||
device=args.device, language=args.language)
|
device=args.device, language=args.language,
|
||||||
|
use_api=not args.no_api)
|
||||||
else:
|
else:
|
||||||
segments = transcribe(audio_path, model_size=model_size,
|
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:
|
if not segments:
|
||||||
print("❌ Не удалось получить сегменты текста!")
|
print("❌ Не удалось получить сегменты текста!")
|
||||||
|
|||||||
@@ -82,6 +82,69 @@ def _draw_text_centered(image: Image.Image, text: str,
|
|||||||
image.paste(overlay.convert("RGB"), (0, 0), overlay)
|
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,
|
def draw_text_with_alpha(image: Image.Image, text: str,
|
||||||
font_active, font_inactive,
|
font_active, font_inactive,
|
||||||
alpha: int = 255, active: bool = True,
|
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
|
gap = (next_seg["start"] - t) if next_seg else 999
|
||||||
|
|
||||||
if active_seg:
|
if active_seg:
|
||||||
# Активный сегмент — рисуем с fade in/out
|
# Активный сегмент — karaoke-эффект если есть word-timestamps
|
||||||
frames_from_start = int((t - active_seg["start"]) * fps)
|
has_words = isinstance(active_seg.get("words"), list) and len(active_seg.get("words", [])) > 0
|
||||||
fade_alpha = min(255, int(255 * frames_from_start / max(FADE_FRAMES, 1)))
|
if has_words:
|
||||||
frames_to_end = int((active_seg["end"] - t) * fps)
|
draw_karaoke_line(frame, active_seg["text"], active_seg["words"], t, font_active)
|
||||||
fade_alpha = min(fade_alpha, int(255 * frames_to_end / max(FADE_FRAMES, 1)))
|
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"],
|
_draw_text_centered(frame, active_seg["text"],
|
||||||
font_active, font_inactive,
|
font_active, font_inactive,
|
||||||
True, max(fade_alpha, 128))
|
True, max(fade_alpha, 128))
|
||||||
|
|
||||||
elif gap > 20 and next_seg:
|
elif gap > 20 and next_seg:
|
||||||
# Длинная пауза (>20s) — показываем прогресс-бар
|
# Длинная пауза (>20s) — показываем прогресс-бар
|
||||||
|
|||||||
@@ -14,11 +14,95 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Whisper транскрипция
|
# 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",
|
def transcribe_whisper(audio_path: str, model_size: str = "base",
|
||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
device: str = "cpu") -> list[dict]:
|
device: str = "cpu") -> list[dict]:
|
||||||
@@ -39,11 +123,23 @@ def transcribe_whisper(audio_path: str, model_size: str = "base",
|
|||||||
text = seg.text.strip()
|
text = seg.text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
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),
|
"start": round(seg.start, 2),
|
||||||
"end": round(seg.end, 2),
|
"end": round(seg.end, 2),
|
||||||
"text": text,
|
"text": text,
|
||||||
})
|
}
|
||||||
|
if words:
|
||||||
|
entry["words"] = words
|
||||||
|
results.append(entry)
|
||||||
return results
|
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,
|
def transcribe(audio_path: str, text_path: Optional[str] = None,
|
||||||
model_size: str = "base", device: str = "cpu",
|
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)
|
model_size — размер Whisper-модели (tiny, base, small, medium, large)
|
||||||
device — "cpu" или "cuda"
|
device — "cpu" или "cuda"
|
||||||
language — код языка (напр. "ru", "en") или None для авто
|
language — код языка (напр. "ru", "en") или None для авто
|
||||||
|
use_api — использовать OpenAI API (по умолч. True)
|
||||||
|
|
||||||
Возвращает: [{start, end, text}, ...]
|
Возвращает: [{start, end, text, words?}, ...]
|
||||||
"""
|
"""
|
||||||
if text_path:
|
if text_path:
|
||||||
ext = Path(text_path).suffix.lower()
|
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}")
|
print(f"[transcribe] Неподдерживаемый формат: {text_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
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})…")
|
print(f"[transcribe] Запускаем Whisper ({model_size}, {device})…")
|
||||||
return transcribe_whisper(audio_path, model_size, language, device)
|
return transcribe_whisper(audio_path, model_size, language, device)
|
||||||
|
|||||||
Reference in New Issue
Block a user