aMuseMe / src /amuseme /transcriber.py
Blazestorm001's picture
chore: tidy Space repository structure
08ab8f1 verified
Raw
History Blame Contribute Delete
17.5 kB
"""
transcriber.py — Audio → word-level timestamps + frame metadata generation using Whisper and MiniCPM5-1B
"""
import os
import re
import math
from dataclasses import dataclass
from faster_whisper import WhisperModel
from amuseme.logger import get_logger
from .frame_metadata import FrameAnim, Frame, SongFrames
logger = get_logger("transcriber")
@dataclass
class Word:
text: str
start: float
end: float
@dataclass
class FrameMeta:
words: list[Word]
frame_animation: FrameAnim
# Gap in seconds between words that triggers a new line fallback
SILENCE_GAP = 0.5
# Max words per line before forcing a break fallback
MAX_WORDS_PER_LINE = 7
# Lines shorter than this get merged into a neighbor so the on-screen text
# changes less often and reads as a complete phrase rather than a fragment.
MIN_WORDS_PER_LINE = 4
# ─── Observability helpers ──────────────────────────────────────────────────
# Human-readable renderings of each step's input/output, written to the shared
# logger (logs/model_io.log + console) so the whole pipeline is inspectable.
def format_transcript(words: list["Word"]) -> str:
"""Whisper output as an indexed, timestamped, readable transcript."""
if not words:
return "(no words)"
sentence = " ".join(w.text for w in words)
rows = [f" [{i:>3}] {w.start:6.2f}-{w.end:6.2f}s {w.text}" for i, w in enumerate(words)]
return f"transcript: {sentence}\n" + "\n".join(rows)
def format_frames(metas: list["FrameMeta"]) -> str:
"""FrameMeta list as readable lines with their frame-level effects."""
if not metas:
return "(no frames)"
out = []
for fi, m in enumerate(metas):
text = " ".join(w.text for w in m.words)
head = f" Frame {fi:>2}: \"{text}\""
if m.frame_animation != FrameAnim.none:
head += f" [frame_animation={m.frame_animation.value}]"
out.append(head)
return "\n".join(out)
_model = None
def _load_model(model_size: str = "large-v3"):
global _model
if _model is None:
if model_size == "turbo":
model_size = "large-v3-turbo"
device = "cpu" if os.environ.get("FORCE_CPU") == "1" else "cuda"
logger.info(f"Loading Whisper {model_size} on {device}...")
compute_type = "float16" if device == "cuda" else "int8"
try:
_model = WhisperModel(model_size, device=device, compute_type=compute_type)
except Exception as e:
logger.warning(f"Failed to load {model_size} with {compute_type}: {e}. Falling back to float32.")
_model = WhisperModel(model_size, device=device, compute_type="float32")
return _model
_demucs_model = None
def _separate_vocals(audio_path: str) -> str:
"""Isolate vocals from a song using Demucs. Returns path to vocals wav."""
import tempfile
import torch
import torchaudio
from demucs.pretrained import get_model
from demucs.apply import apply_model
global _demucs_model
if _demucs_model is None:
logger.info("Loading Demucs htdemucs model...")
_demucs_model = get_model('htdemucs')
device = "cpu" if os.environ.get("FORCE_CPU") == "1" else ("cuda" if torch.cuda.is_available() else "cpu")
_demucs_model.to(device)
device = next(_demucs_model.parameters()).device
wav, sr = torchaudio.load(audio_path)
if sr != _demucs_model.samplerate:
wav = torchaudio.functional.resample(wav, sr, _demucs_model.samplerate)
sr = _demucs_model.samplerate
wav = wav.to(device)
logger.info(f"Separating vocals from {audio_path}...")
with torch.no_grad():
sources = apply_model(_demucs_model, wav.unsqueeze(0), split=True, overlap=0.25)[0]
vocals_idx = _demucs_model.sources.index('vocals')
vocals = sources[vocals_idx].cpu()
vocals_path = os.path.join(tempfile.gettempdir(), "amuseme_vocals.wav")
torchaudio.save(vocals_path, vocals, sr)
logger.info(f"Vocal separation complete. Saved to {vocals_path}")
return vocals_path
def transcribe(audio_path: str, lyrics_override: str = "", model_size: str = "large-v3",
use_demucs: bool = True, condition_on_previous_text: bool = True, use_vad: bool = True,
theme: str = "Dark", visual_prompt: str = "") -> list[FrameMeta]:
"""
Transcribe audio and return timestamped display frames with metadata.
"""
if use_demucs:
vocals_path = _separate_vocals(audio_path)
else:
logger.info(f"Skipping Demucs separation, using original audio.")
vocals_path = audio_path
model = _load_model(model_size)
logger.info(
"=== WHISPER INPUT ===\n"
f" audio={audio_path}\n"
f" model={model_size} demucs={use_demucs} vad={use_vad} "
f"condition_on_previous_text={condition_on_previous_text}\n"
f" lyrics_override={'yes' if lyrics_override.strip() else 'no'}"
)
segments_gen, info = model.transcribe(
vocals_path,
word_timestamps=True,
condition_on_previous_text=condition_on_previous_text,
compression_ratio_threshold=1.50,
temperature=(0.0, 0.2),
no_speech_threshold=0.8,
vad_filter=use_vad,
vad_parameters=dict(threshold=0.05, min_silence_duration_ms=2000, speech_pad_ms=2000, min_speech_duration_ms=50) if use_vad else None,
initial_prompt=lyrics_override if lyrics_override.strip() else None
)
segments = list(segments_gen)
raw_words: list[Word] = []
for segment in segments:
for w in getattr(segment, "words", []):
clean = re.sub(r"[^\w\s''-]", "", w.word).strip()
if clean:
raw_words.append(Word(text=clean, start=w.start, end=w.end))
if not raw_words:
logger.warning(f"No words extracted from {audio_path}")
return []
logger.info(
f"=== WHISPER OUTPUT === {len(raw_words)} words "
f"(detected language: {getattr(info, 'language', '?')})\n"
f"{format_transcript(raw_words)}"
)
return _generate_frame_metadata(raw_words, theme, visual_prompt)
_minicpm_model = None
_minicpm_generator = None
_minicpm_tokenizer = None
def _load_minicpm():
global _minicpm_model, _minicpm_generator, _minicpm_tokenizer
if _minicpm_model is None:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from outlines import from_transformers, Generator
device = "cpu" if os.environ.get("FORCE_CPU") == "1" else ("cuda" if torch.cuda.is_available() else "cpu")
logger.info(f"Loading MiniCPM5-1B on {device}...")
try:
# MiniCPM5-1B is a standard LlamaForCausalLM — no trust_remote_code needed.
hf_model = AutoModelForCausalLM.from_pretrained(
"openbmb/MiniCPM5-1B",
torch_dtype=torch.bfloat16 if device == "cuda" else torch.float32,
).to(device)
_minicpm_tokenizer = AutoTokenizer.from_pretrained("openbmb/MiniCPM5-1B")
_minicpm_model = from_transformers(hf_model, _minicpm_tokenizer)
_minicpm_generator = Generator(_minicpm_model, SongFrames)
except Exception as e:
logger.error(f"Error loading MiniCPM5-1B via outlines: {e}")
_minicpm_model = "failed"
return _minicpm_model, _minicpm_generator, _minicpm_tokenizer
# Words per LLM chunk. Kept small so the model only has to produce a couple of
# {count, frame_animation} objects per call — a much easier task than
# partitioning a long chunk into several lines.
CHUNK_SIZE = 10
def _build_prompt(chunk_words: list[Word], theme: str, visual_prompt: str) -> str:
"""Build the user message describing one chunk of words."""
text_parts = []
for i, w in enumerate(chunk_words):
if i > 0:
silence = w.start - chunk_words[i - 1].end
if silence > 0.5:
text_parts.append(f"[PAUSE {silence:.1f}s]")
word_str = f"'{w.text}'"
duration = w.end - w.start
if duration > 0.6:
# Flag long-held/sustained notes — these can stand alone as a line.
word_str += f"({duration:.1f}s)"
text_parts.append(word_str)
context_str = " ".join(text_parts)
return f"""LYRICS (in order):
{context_str}
THEME: {theme}
AI BACKGROUND ART STYLE (a separate image model paints a new background every two lines, using this style): {visual_prompt if visual_prompt else 'None'}
Split these {len(chunk_words)} words into short on-screen lines and return JSON for the SongFrames schema.
Each frame has:
- "count": how many of the NEXT words (in order) belong to this line.
- "frame_animation": one effect for the whole line, matching its mood.
RULES:
1. The counts must add up to exactly {len(chunk_words)} (every word used once, in order).
2. Each line should read as a complete phrase — aim for 4 to 7 words per line, never more than 7. Avoid 1-2 word lines unless they are the very last words or a word marked with a long held duration like (1.5s).
3. Use [PAUSE] markers as strong hints for where a line should end.
4. Pick "frame_animation" to match the line's mood and complement the AI background art style above (e.g. "zoom_in" for emphasis, "flash" for a dramatic hit, "fade_to_black" for a quiet ending, "pan_left"/"pan_right" for gentle movement) — most lines should be "none"."""
SYSTEM_PROMPT = (
"You are a lyric video director. You split song lyrics into short on-screen "
"lines and pick one mood-matching animation effect per line. Your output "
"drives the timing of a kinetic-typography video that also has AI-generated "
"background art behind the text, so choose line breaks and animations that "
"complement that art without crowding it. "
"You never change, add, remove or reorder words."
)
def _generate_frame_metadata(words: list[Word], theme: str, visual_prompt: str) -> list[FrameMeta]:
model, generator, tokenizer = _load_minicpm()
if model == "failed" or model is None:
logger.warning("LLM unavailable, falling back to rule-based annotator.")
return _smooth_short_lines(_fallback_annotator(words))
all_metas: list[FrameMeta] = []
total_words = len(words)
start_idx = 0
while start_idx < total_words:
end_idx = min(start_idx + CHUNK_SIZE, total_words)
chunk_words = words[start_idx:end_idx]
user_prompt = _build_prompt(chunk_words, theme, visual_prompt)
prompt = tokenizer.apply_chat_template(
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
tokenize=False,
add_generation_prompt=True,
enable_thinking=False, # annotation task, no reasoning needed
)
logger.info(
f"=== LLM INPUT [chunk {start_idx}-{end_idx - 1}] === (MiniCPM5-1B)\n{user_prompt}"
)
try:
# Outlines guarantees schema-valid JSON; with overrides removed the
# output is just a couple of {count, frame_animation} objects.
result_str = generator(prompt, max_new_tokens=256, do_sample=True, temperature=0.7, top_p=0.95)
logger.info(f"=== LLM RAW OUTPUT [chunk {start_idx}-{end_idx - 1}] ===\n{result_str}")
result = SongFrames.model_validate_json(result_str)
chunk_metas = _reconcile_chunk(words, start_idx, end_idx, result.frames)
logger.info(
f"=== LLM PARSED FRAMES [chunk {start_idx}-{end_idx - 1}] ===\n"
f"{format_frames(chunk_metas)}"
)
except Exception as e:
logger.error(
f"Generation failed for chunk {start_idx}-{end_idx - 1}: {e}. "
"Using rule-based fallback annotator for this chunk."
)
chunk_metas = _fallback_annotator(words[start_idx:end_idx])
logger.info(
f"=== FALLBACK FRAMES [chunk {start_idx}-{end_idx - 1}] ===\n"
f"{format_frames(chunk_metas)}"
)
all_metas.extend(chunk_metas)
start_idx = end_idx
all_metas = _smooth_short_lines(all_metas)
logger.info(
f"=== FINAL FRAME METADATA === {len(all_metas)} frames total\n"
f"{format_frames(all_metas)}"
)
return all_metas
def _reconcile_chunk(words: list[Word], start_idx: int, end_idx: int, frames: list[Frame]) -> list[FrameMeta]:
"""
Turn count-based LLM frames into FrameMeta covering exactly [start_idx, end_idx).
Indices are assigned sequentially from the counts, so contiguity/ordering/coverage
are guaranteed regardless of what the model emitted. Counts are clamped to
MAX_WORDS_PER_LINE so a single line can never balloon to the whole chunk; any
words left over (from undershoot or clamping) are packed into additional
MAX_WORDS_PER_LINE-sized lines with frame_animation=none.
"""
metas: list[FrameMeta] = []
cursor = start_idx
for fr in frames:
if cursor >= end_idx:
break
count = max(1, min(fr.count, MAX_WORDS_PER_LINE))
stop = min(cursor + count, end_idx)
metas.append(FrameMeta(words=words[cursor:stop], frame_animation=fr.frame_animation))
cursor = stop
# Any words the model failed to account for (undershoot, or overshoot
# clamped above): pack into additional clean, short lines.
while cursor < end_idx:
stop = min(cursor + MAX_WORDS_PER_LINE, end_idx)
metas.append(FrameMeta(words=words[cursor:stop], frame_animation=FrameAnim.none))
cursor = stop
return metas
# A short line whose words span at least this long (e.g. one held/sustained
# note) reads fine on its own — don't merge it away just for being short.
SUSTAINED_LINE_DURATION = 1.2
def _line_duration(meta: FrameMeta) -> float:
if not meta.words:
return 0.0
return meta.words[-1].end - meta.words[0].start
def _smooth_short_lines(metas: list[FrameMeta]) -> list[FrameMeta]:
"""
Merge lines shorter than MIN_WORDS_PER_LINE into a neighboring line,
unless the short line is actually a sustained/held word (long duration),
in which case it's left standing on its own.
Short lines (especially single words) cause the background/text to change
on almost every word, which feels frantic and breaks the sentence up.
Words stay in chronological order, so a merged FrameMeta's on-screen
window (via get_frame_times) still spans correctly.
"""
if len(metas) <= 1:
return metas
result: list[FrameMeta] = []
i = 0
while i < len(metas):
cur = metas[i]
if len(cur.words) < MIN_WORDS_PER_LINE and _line_duration(cur) < SUSTAINED_LINE_DURATION:
nxt = metas[i + 1] if i + 1 < len(metas) else None
# Prefer merging forward (keeps reading order natural).
if nxt is not None and len(cur.words) + len(nxt.words) <= MAX_WORDS_PER_LINE:
merged_anim = cur.frame_animation if cur.frame_animation != FrameAnim.none else nxt.frame_animation
result.append(FrameMeta(words=cur.words + nxt.words, frame_animation=merged_anim))
i += 2
continue
# Too big to merge fully — borrow just enough words from the
# front of the next line so `cur` reaches MIN_WORDS_PER_LINE.
if nxt is not None:
moved = min(MIN_WORDS_PER_LINE - len(cur.words), len(nxt.words) - 1)
if moved > 0:
cur = FrameMeta(words=cur.words + nxt.words[:moved], frame_animation=cur.frame_animation)
nxt.words = nxt.words[moved:]
result.append(cur)
i += 1
continue
# Otherwise fold backward into the line we just emitted.
if result and len(result[-1].words) + len(cur.words) <= MAX_WORDS_PER_LINE:
result[-1].words.extend(cur.words)
i += 1
continue
# Too big to fold fully backward — borrow from the end of the
# previous line instead.
if result:
prev = result[-1]
moved = min(MIN_WORDS_PER_LINE - len(cur.words), len(prev.words) - 1)
if moved > 0:
cur.words = prev.words[-moved:] + cur.words
prev.words = prev.words[:-moved]
result.append(cur)
i += 1
return result
def _fallback_annotator(words: list[Word]) -> list[FrameMeta]:
"""
Rule-based annotator used when the LLM is unavailable or a chunk fails.
Groups words into lines by silence gaps / max line length, with no
frame-level animation.
"""
if not words:
return []
metas: list[FrameMeta] = []
current: list[Word] = [words[0]]
for i in range(1, len(words)):
silence = words[i].start - words[i - 1].end
if len(current) >= MAX_WORDS_PER_LINE or silence > SILENCE_GAP:
metas.append(FrameMeta(words=current, frame_animation=FrameAnim.none))
current = []
current.append(words[i])
if current:
metas.append(FrameMeta(words=current, frame_animation=FrameAnim.none))
return metas