Files
exportAnything-ComfyUI-Diff…/timed_lyrics_nodes.py
danrh 8f23c95546 Add synchronized music-video production planning
Add measured-audio, timed-lyrics, H3 lane planning, identity relay, upload-song routing, model-specific camera contracts, LTX long-horizon planning, workflow migrations, the validated V6 example, regression coverage, and an Unreleased changelog.
2026-08-23 08:03:39 -05:00

1192 lines
46 KiB
Python

# Copyright (c) 2026 exportAnything. All rights reserved.
# SPDX-License-Identifier: MIT
"""Fail-safe vocal activity, transcription, and authored-lyric alignment.
This node deliberately treats timing as optional evidence. A failed separator,
missing transcript, weak lyric match, or model exception returns a structured
``natural_fallback`` report; it never blocks the downstream video workflow.
Whisper is only evaluated for the explicit Lyrics + lip sync mode.
"""
from __future__ import annotations
from collections import OrderedDict
from difflib import SequenceMatcher
import hashlib
import inspect
import json
import math
import re
import struct
import threading
from typing import Any, Callable, Mapping, Sequence
import torch
import torch.nn.functional as torch_functional
CATEGORY = "prompt/diffusiongemma/audio-production"
TIMED_LYRICS_REPORT_SCHEMA = "diffusiongemma.timed_lyrics_report"
TIMED_LYRICS_REPORT_VERSION = 1
PERFORMANCE_MODES = (
"Natural / audio-led sync",
"Dance / music sync",
"Lyrics + lip sync",
)
LANGUAGES = (
"en",
"auto",
"fr",
"es",
"de",
"it",
"pt",
"nl",
"ru",
"zh",
"ja",
"ko",
)
ACTIVITY_WINDOW_SECONDS = 0.5
MAX_WHISPER_CHUNK_SECONDS = 15.0
WHISPER_SAMPLE_RATE = 16_000
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_TIMESTAMP_TOKEN_RE = re.compile(r"^<\|([0-9]+(?:\.[0-9]+)?)\|>$")
_SECTION_HEADER_RE = re.compile(r"^\s*\[[^\]]{1,80}\]\s*$")
_PLAIN_SECTION_RE = re.compile(
r"^\s*(?:intro|outro|verse|pre[ -]?chorus|chorus|refrain|hook|bridge|"
r"interlude|break|breakdown|instrumental|solo|drop|post[ -]?chorus)"
r"(?:\s+[0-9ivx]+)?\s*:?\s*$",
re.IGNORECASE,
)
_WORD_RE = re.compile(r"[^\W_]+(?:['\u2019][^\W_]+)?", re.UNICODE)
_CACHE_MAX_ENTRIES = 8
_ANALYSIS_CACHE: OrderedDict[tuple[Any, ...], tuple[str, str, bool, str]] = OrderedDict()
_CACHE_LOCK = threading.RLock()
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _lyrics_sha256(lyrics: Any) -> str:
return hashlib.sha256(str(lyrics or "").encode("utf-8")).hexdigest()
def _audio_parts(audio: Any, label: str) -> tuple[torch.Tensor, int]:
if not isinstance(audio, Mapping):
raise ValueError(f"{label} must be a ComfyUI AUDIO mapping.")
waveform = audio.get("waveform")
if not isinstance(waveform, torch.Tensor) or waveform.ndim != 3:
raise ValueError(f"{label} waveform must have shape [batch, channels, samples].")
if any(int(size) <= 0 for size in waveform.shape):
raise ValueError(f"{label} waveform dimensions must be non-empty.")
try:
sample_rate = int(audio.get("sample_rate", 0))
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"{label} sample rate must be a positive integer.") from exc
if sample_rate <= 0:
raise ValueError(f"{label} sample rate must be a positive integer.")
return waveform, sample_rate
def _waveform_sha256(audio: Any) -> str:
"""Match the exact decoded-waveform identity used by audio production."""
waveform, sample_rate = _audio_parts(audio, "final_audio")
value = waveform.detach()
if value.device.type != "cpu":
value = value.to("cpu")
value = value.contiguous()
payload = memoryview(value.view(torch.uint8).numpy()).cast("B")
digest = hashlib.sha256()
digest.update(b"diffusiongemma-audio-waveform@1\0")
digest.update(struct.pack("<Q", sample_rate))
digest.update(str(value.dtype).encode("ascii"))
digest.update(b"\0")
digest.update(struct.pack("<I", value.ndim))
for size in value.shape:
digest.update(struct.pack("<Q", int(size)))
for offset in range(0, len(payload), 1024 * 1024):
digest.update(payload[offset : offset + 1024 * 1024])
return digest.hexdigest()
def _positive_finite(value: Any, label: str, *, allow_zero: bool = False) -> float:
if isinstance(value, bool):
raise ValueError(f"{label} must be a finite number.")
try:
number = float(value)
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError(f"{label} must be a finite number.") from exc
if not math.isfinite(number) or number < 0.0 or (not allow_zero and number <= 0.0):
qualifier = "non-negative" if allow_zero else "positive"
raise ValueError(f"{label} must be a finite {qualifier} number.")
return number
def _normalize_mode(value: Any) -> str:
key = re.sub(r"\s+", " ", str(value or "").strip()).casefold()
aliases = {
"natural": "Natural / audio-led sync",
"natural / audio led sync": "Natural / audio-led sync",
"natural / audio-led sync": "Natural / audio-led sync",
"audio led": "Natural / audio-led sync",
"audio-led": "Natural / audio-led sync",
"dance": "Dance / music sync",
"dance / music sync": "Dance / music sync",
"lyrics": "Lyrics + lip sync",
"lyrics + lip sync": "Lyrics + lip sync",
}
if key not in aliases:
raise ValueError("performance_mode must be Natural, Dance, or Lyrics + lip sync.")
return aliases[key]
def _model_from_pipeline(pipeline: Any) -> Any:
if isinstance(pipeline, Mapping):
return pipeline.get("model")
return getattr(pipeline, "model", None)
def _processor_from_pipeline(pipeline: Any) -> Any:
if isinstance(pipeline, Mapping):
return pipeline.get("processor")
return getattr(pipeline, "processor", None)
def _model_id(pipeline: Any) -> str:
if isinstance(pipeline, Mapping):
explicit = pipeline.get("model_id") or pipeline.get("name")
if explicit:
return str(explicit)
explicit = getattr(pipeline, "model_id", None)
if explicit:
return str(explicit)
model = _model_from_pipeline(pipeline)
config = getattr(model, "config", None)
for value in (
getattr(config, "_name_or_path", None),
getattr(config, "name_or_path", None),
getattr(model, "name_or_path", None),
):
if value:
return str(value)
if model is not None:
return f"{type(model).__module__}.{type(model).__qualname__}"
return "unknown-whisper-pipeline"
def _mono_cpu(audio: Any, label: str) -> tuple[torch.Tensor, int, float]:
waveform, sample_rate = _audio_parts(audio, label)
value = waveform.detach().to(device="cpu", dtype=torch.float32)
if not bool(torch.isfinite(value).all()):
raise ValueError(f"{label} contains NaN or infinite samples.")
mono = value.mean(dim=(0, 1)).contiguous()
return mono, sample_rate, mono.numel() / float(sample_rate)
def _resample_1d(value: torch.Tensor, source_rate: int, target_rate: int) -> torch.Tensor:
if source_rate == target_rate:
return value.contiguous()
target_samples = max(1, int(round(value.numel() * target_rate / float(source_rate))))
return torch_functional.interpolate(
value.reshape(1, 1, -1),
size=target_samples,
mode="linear",
align_corners=False,
).reshape(-1).contiguous()
def _stem_aligned_to_mix(
vocal_stem: Any,
final_audio: Any,
) -> tuple[torch.Tensor, torch.Tensor, int, dict[str, Any]]:
mix, mix_rate, mix_duration = _mono_cpu(final_audio, "final_audio")
stem, stem_rate, stem_duration = _mono_cpu(vocal_stem, "vocal_stem")
duration_error = abs(stem_duration - mix_duration)
tolerance = max(0.05, 1.0 / mix_rate)
if duration_error > tolerance:
raise ValueError(
"vocal_stem duration is not aligned to final_audio "
f"({stem_duration:.3f}s versus {mix_duration:.3f}s)."
)
stem = _resample_1d(stem, stem_rate, mix_rate)
if abs(stem.numel() - mix.numel()) > max(1, int(round(tolerance * mix_rate))):
raise ValueError("vocal_stem sample count cannot be aligned to final_audio.")
if stem.numel() < mix.numel():
stem = torch_functional.pad(stem, (0, mix.numel() - stem.numel()))
else:
stem = stem[: mix.numel()]
return stem.contiguous(), mix, mix_rate, {
"mix_sample_rate": mix_rate,
"stem_source_sample_rate": stem_rate,
"mix_duration_seconds": mix_duration,
"stem_duration_seconds": stem_duration,
"duration_error_seconds": duration_error,
}
def _stem_full_mix_similarity(stem: torch.Tensor, mix: torch.Tensor) -> dict[str, Any]:
# Bound the diagnostic calculation for long songs without changing its
# deterministic coverage of the waveform.
maximum_points = 1_000_000
stride = max(1, int(math.ceil(max(stem.numel(), mix.numel()) / maximum_points)))
left = stem[::stride].to(torch.float64)
right = mix[::stride].to(torch.float64)
mix_energy = float(torch.mean(right.square()).item())
stem_energy = float(torch.mean(left.square()).item())
if mix_energy <= 1.0e-14 and stem_energy <= 1.0e-14:
return {
"correlation": 1.0,
"optimal_mix_gain": 1.0,
"normalized_residual_rms": 0.0,
"near_identical_full_mix": True,
}
denominator = float(torch.dot(right, right).item())
gain = float(torch.dot(left, right).item()) / max(denominator, 1.0e-14)
residual = left - gain * right
residual_ratio = math.sqrt(float(torch.mean(residual.square()).item())) / max(
math.sqrt(stem_energy), 1.0e-12
)
centered_left = left - left.mean()
centered_right = right - right.mean()
corr_denominator = float(
torch.linalg.vector_norm(centered_left) * torch.linalg.vector_norm(centered_right)
)
correlation = (
float(torch.dot(centered_left, centered_right).item()) / corr_denominator
if corr_denominator > 1.0e-14
else 0.0
)
near_identical = correlation >= 0.999 and residual_ratio <= 0.025
return {
"correlation": round(correlation, 8),
"optimal_mix_gain": round(gain, 8),
"normalized_residual_rms": round(residual_ratio, 8),
"near_identical_full_mix": near_identical,
}
def _interval_complement(
intervals: Sequence[Mapping[str, float]], duration: float
) -> list[dict[str, float]]:
result: list[dict[str, float]] = []
cursor = 0.0
for interval in intervals:
start = max(cursor, float(interval["start_seconds"]))
end = min(duration, float(interval["end_seconds"]))
if start > cursor + 1.0e-6:
result.append(
{"start_seconds": round(cursor, 6), "end_seconds": round(start, 6)}
)
cursor = max(cursor, end)
if cursor < duration - 1.0e-6:
result.append(
{"start_seconds": round(cursor, 6), "end_seconds": round(duration, 6)}
)
return result
def _vocal_activity(
stem: torch.Tensor,
mix: torch.Tensor,
sample_rate: int,
excerpt_start: float,
excerpt_duration: float,
) -> tuple[list[dict[str, float]], list[dict[str, float]], dict[str, Any]]:
start_sample = int(round(excerpt_start * sample_rate))
end_sample = int(round((excerpt_start + excerpt_duration) * sample_rate))
excerpt_stem = stem[start_sample:end_sample]
excerpt_mix = mix[start_sample:end_sample]
expected_samples = int(round(excerpt_duration * sample_rate))
if excerpt_stem.numel() + 1 < expected_samples or excerpt_mix.numel() + 1 < expected_samples:
raise ValueError("The selected excerpt extends beyond final_audio or vocal_stem.")
usable = min(excerpt_stem.numel(), excerpt_mix.numel())
excerpt_stem = excerpt_stem[:usable]
excerpt_mix = excerpt_mix[:usable]
window_samples = max(1, int(round(ACTIVITY_WINDOW_SECONDS * sample_rate)))
records: list[dict[str, float | bool]] = []
for offset in range(0, usable, window_samples):
stop = min(usable, offset + window_samples)
stem_window = excerpt_stem[offset:stop]
mix_window = excerpt_mix[offset:stop]
stem_rms = math.sqrt(float(torch.mean(stem_window.square()).item()))
mix_rms = math.sqrt(float(torch.mean(mix_window.square()).item()))
records.append(
{
"start": offset / float(sample_rate),
"end": stop / float(sample_rate),
"stem_rms": stem_rms,
"mix_rms": mix_rms,
"ratio": stem_rms / max(mix_rms, 1.0e-8),
"active": False,
}
)
positive_rms = torch.tensor(
[float(item["stem_rms"]) for item in records], dtype=torch.float64
)
p90 = float(torch.quantile(positive_rms, 0.90).item()) if records else 0.0
absolute_threshold = max(3.0e-4, min(0.02, 0.12 * p90))
ratio_threshold = 0.06
for item in records:
item["active"] = bool(
float(item["stem_rms"]) >= absolute_threshold
and float(item["ratio"]) >= ratio_threshold
)
# Smooth one-window separator dropouts, then discard isolated low-energy
# blips. A genuinely strong half-second phrase remains eligible.
for index in range(1, len(records) - 1):
if (
not bool(records[index]["active"])
and bool(records[index - 1]["active"])
and bool(records[index + 1]["active"])
):
records[index]["active"] = True
for index, item in enumerate(records):
if not bool(item["active"]):
continue
left = index > 0 and bool(records[index - 1]["active"])
right = index + 1 < len(records) and bool(records[index + 1]["active"])
if not left and not right and float(item["stem_rms"]) < max(0.002, 2.5 * absolute_threshold):
item["active"] = False
intervals: list[dict[str, float]] = []
current_start: float | None = None
current_end = 0.0
for item in records:
if bool(item["active"]):
if current_start is None:
current_start = float(item["start"])
current_end = float(item["end"])
elif current_start is not None:
intervals.append(
{
"start_seconds": round(current_start, 6),
"end_seconds": round(min(excerpt_duration, current_end), 6),
}
)
current_start = None
if current_start is not None:
intervals.append(
{
"start_seconds": round(current_start, 6),
"end_seconds": round(min(excerpt_duration, current_end), 6),
}
)
active_seconds = sum(
max(0.0, item["end_seconds"] - item["start_seconds"]) for item in intervals
)
diagnostics = {
"window_seconds": ACTIVITY_WINDOW_SECONDS,
"window_count": len(records),
"absolute_stem_rms_threshold": absolute_threshold,
"stem_to_mix_rms_ratio_threshold": ratio_threshold,
"active_window_count": sum(bool(item["active"]) for item in records),
"active_seconds": active_seconds,
"active_coverage": active_seconds / max(excerpt_duration, 1.0e-9),
"stem_rms_p90": p90,
}
return intervals, _interval_complement(intervals, excerpt_duration), diagnostics
def _authored_lines(lyrics: Any) -> list[str]:
result: list[str] = []
for raw_line in str(lyrics or "").splitlines():
line = raw_line.strip()
if not line or _SECTION_HEADER_RE.fullmatch(line) or _PLAIN_SECTION_RE.fullmatch(line):
continue
result.append(line)
return result
def _normalized_words(text: Any) -> list[str]:
return [
match.group(0).replace("\u2019", "'").casefold()
for match in _WORD_RE.finditer(str(text or ""))
]
def _transcript_similarity(left: Any, right: Any) -> float:
left_words = _normalized_words(left)
right_words = _normalized_words(right)
if not left_words or not right_words:
return 0.0
left_text = " ".join(left_words)
right_text = " ".join(right_words)
if left_text == right_text:
return 1.0
sequence_score = SequenceMatcher(None, left_text, right_text).ratio()
left_counts: dict[str, int] = {}
right_counts: dict[str, int] = {}
for word in left_words:
left_counts[word] = left_counts.get(word, 0) + 1
for word in right_words:
right_counts[word] = right_counts.get(word, 0) + 1
overlap = sum(min(count, right_counts.get(word, 0)) for word, count in left_counts.items())
precision = overlap / len(left_words)
recall = overlap / len(right_words)
word_f1 = 2.0 * precision * recall / max(precision + recall, 1.0e-12)
containment = min(len(left_words), len(right_words)) / max(len(left_words), len(right_words))
if left_text in right_text or right_text in left_text:
sequence_score = max(sequence_score, containment)
return max(0.0, min(1.0, 0.55 * sequence_score + 0.45 * word_f1))
def _align_phrases(
phrases: Sequence[Mapping[str, Any]],
lines: Sequence[str],
excerpt_start: float,
song_duration: float,
minimum_confidence: float,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
events: list[dict[str, Any]] = []
minimum_line_index = 0
candidate_evaluations = 0
weakest = 1.0
for phrase in phrases:
transcript = str(phrase.get("text", "")).strip()
if not _normalized_words(transcript):
continue
midpoint = excerpt_start + 0.5 * (
float(phrase["start_seconds"]) + float(phrase["end_seconds"])
)
expected_position = max(0.0, min(1.0, midpoint / max(song_duration, 1.0e-9)))
expected_index = expected_position * max(0, len(lines) - 1)
best: tuple[float, float, int, int, list[str]] | None = None
for start in range(minimum_line_index, len(lines)):
for count in range(1, min(3, len(lines) - start) + 1):
selected = list(lines[start : start + count])
similarity = _transcript_similarity(transcript, " ".join(selected))
candidate_center = start + 0.5 * (count - 1)
position_score = 1.0 - abs(candidate_center - expected_index) / max(len(lines), 1)
# Song position is deliberately weak: it resolves repeated choruses
# but cannot rescue an uncertain lexical match.
rank_score = similarity + 0.03 * max(0.0, position_score)
candidate_evaluations += 1
candidate = (rank_score, similarity, start, count, selected)
if best is None or candidate[:4] > best[:4]:
best = candidate
if best is None or best[1] < minimum_confidence:
observed = 0.0 if best is None else best[1]
raise ValueError(
"alignment_confidence_below_threshold: "
f"{observed:.3f} < {minimum_confidence:.3f} for {transcript!r}"
)
_, confidence, start, count, selected = best
weakest = min(weakest, confidence)
events.append(
{
"start_seconds": round(float(phrase["start_seconds"]), 6),
"end_seconds": round(float(phrase["end_seconds"]), 6),
"text": transcript,
"transcript": transcript,
"authored_lines": selected,
"confidence": round(confidence, 6),
}
)
# Whisper may split one authored line into several timestamp phrases.
# Keep the lower bound non-decreasing (rather than strictly advancing)
# so adjacent fragments can safely resolve to that same authored line.
minimum_line_index = start
if not events:
raise ValueError("whisper_returned_no_timestamped_vocal_phrases")
return events, {
"event_count": len(events),
"authored_line_count": len(lines),
"candidate_evaluations": candidate_evaluations,
"minimum_event_confidence": weakest,
"monotonic_authored_line_alignment": True,
"song_position_tie_break_weight": 0.03,
}
def _timestamp_value(token: Any) -> float | None:
match = _TIMESTAMP_TOKEN_RE.fullmatch(str(token or ""))
if not match:
return None
try:
value = float(match.group(1))
except (TypeError, ValueError, OverflowError):
return None
return value if math.isfinite(value) else None
def _tokens_to_text(tokens: Sequence[Any], tokenizer: Any = None) -> str:
values = [str(token) for token in tokens if _timestamp_value(token) is None]
values = [
token
for token in values
if not (token.startswith("<|") and token.endswith("|>"))
]
if tokenizer is not None:
converter = getattr(tokenizer, "convert_tokens_to_string", None)
if callable(converter):
try:
return str(converter(values)).strip()
except (TypeError, ValueError, RuntimeError):
pass
text = "".join(values).replace("\u0120", " ").replace("\u2581", " ")
if not text.strip():
text = " ".join(values)
return re.sub(r"\s+", " ", text).strip()
def _response_phrases(
response: Any,
chunk_duration: float,
tokenizer: Any = None,
) -> list[dict[str, Any]]:
if isinstance(response, tuple):
mappings = [item for item in response if isinstance(item, Mapping)]
response = mappings[-1] if mappings else (response[0] if response else None)
if not isinstance(response, Mapping):
return []
chunks = response.get("chunks")
result: list[dict[str, Any]] = []
if isinstance(chunks, Sequence) and not isinstance(chunks, (str, bytes, bytearray)):
for chunk in chunks:
if not isinstance(chunk, Mapping):
continue
stamp = chunk.get("timestamp", chunk.get("timestamps"))
if not isinstance(stamp, Sequence) or isinstance(stamp, (str, bytes)) or len(stamp) != 2:
continue
try:
start, end = float(stamp[0]), float(stamp[1])
except (TypeError, ValueError, OverflowError):
continue
text = str(chunk.get("text", "")).strip()
if (
text
and math.isfinite(start)
and math.isfinite(end)
and start >= -0.05
and end > start
and start < chunk_duration + 0.05
):
result.append(
{
"start_seconds": max(0.0, start),
"end_seconds": min(chunk_duration, end),
"text": text,
}
)
if result:
return result
tokens = response.get("tokens")
if not isinstance(tokens, Sequence) or isinstance(tokens, (str, bytes, bytearray)):
return []
markers = [
(index, value)
for index, token in enumerate(tokens)
if (value := _timestamp_value(token)) is not None
]
for marker_index, (position, start) in enumerate(markers):
next_position = len(tokens)
end = chunk_duration
if marker_index + 1 < len(markers):
next_position, end = markers[marker_index + 1]
text = _tokens_to_text(tokens[position + 1 : next_position], tokenizer)
if (
text
and start >= -0.05
and end > start
and start < chunk_duration + 0.05
):
result.append(
{
"start_seconds": max(0.0, start),
"end_seconds": min(chunk_duration, end),
"text": text,
}
)
return result
def _call_with_supported_arguments(function: Callable[..., Any], **kwargs: Any) -> Any:
try:
signature = inspect.signature(function)
except (TypeError, ValueError):
return function(kwargs["audio"], kwargs.get("language", "en"), True)
accepts_kwargs = any(
parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in signature.parameters.values()
)
supported = (
kwargs
if accepts_kwargs
else {name: value for name, value in kwargs.items() if name in signature.parameters}
)
return function(**supported)
def _custom_transcriber(pipeline: Any) -> Callable[..., Any] | None:
if isinstance(pipeline, Mapping) and callable(pipeline.get("transcribe")):
return pipeline["transcribe"]
transcribe = getattr(pipeline, "transcribe", None)
if callable(transcribe):
return transcribe
if callable(pipeline) and not isinstance(pipeline, Mapping):
return pipeline
return None
def _runtime_device(model: Any) -> Any:
try:
from comfy.model_management import get_torch_device
return get_torch_device()
except (ImportError, AttributeError, RuntimeError):
return getattr(model, "device", torch.device("cpu"))
def _prepare_model(pipeline: Any) -> None:
if _custom_transcriber(pipeline) is not None:
return
model = _model_from_pipeline(pipeline)
processor = _processor_from_pipeline(pipeline)
if model is None or processor is None:
raise ValueError("whisper_pipeline must contain MTB processor and model values.")
mover = getattr(model, "to", None)
if callable(mover):
mover(_runtime_device(model))
evaluator = getattr(model, "eval", None)
if callable(evaluator):
evaluator()
def _offload_model(pipeline: Any) -> None:
model = _model_from_pipeline(pipeline)
mover = getattr(model, "to", None)
if callable(mover):
try:
mover("cpu")
except (RuntimeError, TypeError, ValueError):
pass
try:
torch.cuda.empty_cache()
except (RuntimeError, AttributeError):
pass
def _direct_mtb_transcription(
pipeline: Any,
mono: torch.Tensor,
sample_rate: int,
language: str,
) -> Mapping[str, Any]:
processor = _processor_from_pipeline(pipeline)
model = _model_from_pipeline(pipeline)
if processor is None or model is None:
raise ValueError("whisper_pipeline must contain MTB processor and model values.")
audio = _resample_1d(mono, sample_rate, WHISPER_SAMPLE_RATE)
processed = processor(audio, sampling_rate=WHISPER_SAMPLE_RATE, return_tensors="pt")
input_features = (
processed.get("input_features")
if isinstance(processed, Mapping)
else getattr(processed, "input_features", None)
)
if not isinstance(input_features, torch.Tensor):
raise ValueError("Whisper processor did not return input_features.")
device = getattr(model, "device", torch.device("cpu"))
model_dtype = getattr(model, "dtype", None)
if not isinstance(model_dtype, torch.dtype):
try:
model_dtype = next(model.parameters()).dtype
except (AttributeError, StopIteration, TypeError):
model_dtype = None
if (
isinstance(model_dtype, torch.dtype)
and bool(torch.is_floating_point(input_features))
and (model_dtype.is_floating_point or model_dtype.is_complex)
):
input_features = input_features.to(device=device, dtype=model_dtype)
else:
input_features = input_features.to(device)
generate_kwargs: dict[str, Any] = {
"task": "transcribe",
"return_timestamps": True,
"no_repeat_ngram_size": 3,
"num_beams": 5,
"length_penalty": 1.0,
}
if language != "auto":
generate_kwargs["language"] = language
attention_mask = (
processed.get("attention_mask")
if isinstance(processed, Mapping)
else getattr(processed, "attention_mask", None)
)
if isinstance(attention_mask, torch.Tensor):
generate_kwargs["attention_mask"] = attention_mask.to(device)
max_length = getattr(getattr(model, "config", None), "max_length", None)
if isinstance(max_length, int) and max_length > 0:
generate_kwargs["max_length"] = max_length
with torch.inference_mode():
predicted_ids = model.generate(input_features, **generate_kwargs)
row = predicted_ids[0]
tokenizer = getattr(processor, "tokenizer", None)
if tokenizer is None:
raise ValueError("Whisper processor has no tokenizer.")
tokens = tokenizer.convert_ids_to_tokens(row)
decoded = processor.batch_decode(predicted_ids, skip_special_tokens=True)
return {
"text": str(decoded[0] if decoded else ""),
"tokens": list(tokens),
"language": language,
}
def _transcribe_active_intervals(
pipeline: Any,
stem: torch.Tensor,
sample_rate: int,
excerpt_start: float,
intervals: Sequence[Mapping[str, float]],
language: str,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
phrases: list[dict[str, Any]] = []
chunk_count = 0
transcriber = _custom_transcriber(pipeline)
tokenizer = getattr(_processor_from_pipeline(pipeline), "tokenizer", None)
for interval in intervals:
interval_start = float(interval["start_seconds"])
interval_end = float(interval["end_seconds"])
chunk_start = interval_start
while chunk_start < interval_end - 1.0e-6:
chunk_end = min(interval_end, chunk_start + MAX_WHISPER_CHUNK_SECONDS)
absolute_song_start = excerpt_start + chunk_start
start_sample = int(round(absolute_song_start * sample_rate))
end_sample = int(round((excerpt_start + chunk_end) * sample_rate))
mono = stem[start_sample:end_sample].contiguous()
duration = mono.numel() / float(sample_rate)
if duration <= 0.0 or duration > MAX_WHISPER_CHUNK_SECONDS + 0.01:
raise ValueError("invalid_whisper_chunk_duration")
chunk_audio = {"waveform": mono.reshape(1, 1, -1), "sample_rate": sample_rate}
if transcriber is not None:
response = _call_with_supported_arguments(
transcriber,
audio=chunk_audio,
language=language,
return_timestamps=True,
chunk_offset_seconds=chunk_start,
absolute_song_start_seconds=absolute_song_start,
)
else:
response = _direct_mtb_transcription(pipeline, mono, sample_rate, language)
local_phrases = _response_phrases(response, duration, tokenizer)
for phrase in local_phrases:
start = chunk_start + float(phrase["start_seconds"])
end = chunk_start + float(phrase["end_seconds"])
if end > start and start < interval_end + 0.05:
phrases.append(
{
"start_seconds": max(interval_start, start),
"end_seconds": min(interval_end, end),
"text": str(phrase["text"]).strip(),
}
)
chunk_count += 1
chunk_start = chunk_end
phrases.sort(key=lambda item: (float(item["start_seconds"]), float(item["end_seconds"])))
previous_end = -math.inf
for phrase in phrases:
start = float(phrase["start_seconds"])
end = float(phrase["end_seconds"])
if start + 0.05 < previous_end or end <= start:
raise ValueError("Whisper returned invalid or non-monotonic timestamp offsets.")
previous_end = max(previous_end, end)
return phrases, {
"chunk_count": chunk_count,
"maximum_chunk_seconds": MAX_WHISPER_CHUNK_SECONDS,
"timestamped_phrase_count": len(phrases),
"mtb_thirty_second_offset_logic_used": False,
"chunk_offsets_added_explicitly": True,
}
def _base_report(
*,
performance_mode: str,
lyrics_hash: str,
master_audio_sha256: str,
song_duration: float,
excerpt_start: float,
excerpt_duration: float,
language: str,
minimum_confidence: float,
model_id: str | None,
analysis_status: str,
timing_ready: bool,
) -> dict[str, Any]:
return {
"schema": TIMED_LYRICS_REPORT_SCHEMA,
"version": TIMED_LYRICS_REPORT_VERSION,
"analysis_status": analysis_status,
"timing_ready": timing_ready,
"performance_mode": performance_mode,
"master_audio_sha256": str(master_audio_sha256 or "").strip().lower(),
"lyrics_sha256": lyrics_hash,
"model_id": model_id,
"language": language,
"minimum_alignment_confidence": minimum_confidence,
"song_duration_seconds": song_duration,
"excerpt": {
"start_seconds": excerpt_start,
"duration_seconds": excerpt_duration,
"end_seconds": excerpt_start + excerpt_duration,
},
"vocal_intervals": [],
"instrumental_intervals": [],
"events": [],
"diagnostics": {"cache_hit": False},
"warnings": [],
}
def _preview(report: Mapping[str, Any]) -> str:
events = report.get("events")
if not isinstance(events, list) or not events:
warnings = report.get("warnings")
reason = str(warnings[0]) if isinstance(warnings, list) and warnings else str(
report.get("analysis_status", "not_required")
)
return f"No forced lyric timing — {reason}."
lines = ["Verified authored-lyric timing (excerpt-relative):"]
for event in events:
authored = " / ".join(str(line) for line in event.get("authored_lines", []))
lines.append(
f"{float(event['start_seconds']):06.2f}-{float(event['end_seconds']):06.2f} "
f"[{float(event['confidence']):.2f}] {authored} <- {event['transcript']}"
)
return "\n".join(lines)
def _outputs(report: dict[str, Any], status: str) -> tuple[str, str, bool, str]:
return (_json(report), status, True, _preview(report))
def _fallback(
report: dict[str, Any],
warning: str,
*,
diagnostic: Any = None,
) -> tuple[str, str, bool, str]:
report["analysis_status"] = "natural_fallback"
report["timing_ready"] = False
report["events"] = []
report.setdefault("warnings", []).append(str(warning))
if diagnostic is not None:
report.setdefault("diagnostics", {})["fallback_detail"] = str(diagnostic)[:500]
status = (
"Natural audio-led fallback is safe: lyric timing was not trusted "
f"({warning}). No forced singing instructions should be emitted."
)
return _outputs(report, status)
def _cache_key(
master_hash: str,
excerpt_start: float,
excerpt_duration: float,
lyrics_hash: str,
model_id: str,
language: str,
minimum_confidence: float,
) -> tuple[Any, ...]:
return (
master_hash,
round(excerpt_start, 6),
round(excerpt_duration, 6),
lyrics_hash,
model_id,
language,
round(minimum_confidence, 6),
)
def _cache_get(key: tuple[Any, ...]) -> tuple[str, str, bool, str] | None:
with _CACHE_LOCK:
value = _ANALYSIS_CACHE.get(key)
if value is None:
return None
_ANALYSIS_CACHE.move_to_end(key)
report = json.loads(value[0])
report.setdefault("diagnostics", {})["cache_hit"] = True
report["diagnostics"]["cache_key_sha256"] = hashlib.sha256(
repr(key).encode("utf-8")
).hexdigest()
status = "Timed lyric alignment restored from the deterministic analysis cache."
return _outputs(report, status)
def _cache_put(key: tuple[Any, ...], outputs: tuple[str, str, bool, str]) -> None:
with _CACHE_LOCK:
_ANALYSIS_CACHE[key] = outputs
_ANALYSIS_CACHE.move_to_end(key)
while len(_ANALYSIS_CACHE) > _CACHE_MAX_ENTRIES:
_ANALYSIS_CACHE.popitem(last=False)
class DiffusionGemmaTimedLyricsAnalyzer:
"""Produce optional, evidence-backed lyric timing without blocking video."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"final_audio": ("AUDIO",),
"performance_mode": ("STRING", {"forceInput": True}),
"lyrics": (
"STRING",
{"default": "", "multiline": True, "forceInput": True},
),
"master_audio_sha256": ("STRING", {"forceInput": True}),
"song_duration_seconds": (
"FLOAT",
{"default": 90.0, "min": 0.1, "max": 3600.0, "step": 0.1},
),
"excerpt_start_seconds": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1},
),
"excerpt_duration_seconds": (
"FLOAT",
{"default": 25.0, "min": 0.1, "max": 60.0, "step": 0.1},
),
"language": (list(LANGUAGES), {"default": "en"}),
"minimum_alignment_confidence": (
"FLOAT",
{"default": 0.55, "min": 0.0, "max": 1.0, "step": 0.01},
),
},
"optional": {
"vocal_stem": ("AUDIO", {"lazy": True}),
"whisper_pipeline": ("WHISPER_PIPELINE", {"lazy": True}),
},
}
RETURN_TYPES = ("STRING", "STRING", "BOOLEAN", "STRING")
RETURN_NAMES = (
"timed_lyrics_report_json",
"status",
"safe_ready",
"aligned_preview",
)
FUNCTION = "analyze"
CATEGORY = CATEGORY
DESCRIPTION = (
"Lazily measures a vocal stem and aligns <=15-second Whisper phrases to authored lyrics. "
"Any uncertainty returns a safe Natural/audio-led fallback instead of blocking generation."
)
@classmethod
def check_lazy_status(cls, **kwargs: Any) -> list[str]:
try:
mode = _normalize_mode(kwargs.get("performance_mode", ""))
except ValueError:
return []
if mode != "Lyrics + lip sync":
return []
return [
name
for name in ("vocal_stem", "whisper_pipeline")
if name in kwargs and kwargs[name] is None
]
def analyze(
self,
final_audio: Any,
performance_mode: Any,
lyrics: Any,
master_audio_sha256: Any,
song_duration_seconds: Any,
excerpt_start_seconds: Any,
excerpt_duration_seconds: Any,
language: Any,
minimum_alignment_confidence: Any,
vocal_stem: Any = None,
whisper_pipeline: Any = None,
) -> tuple[str, str, bool, str]:
lyrics_hash = _lyrics_sha256(lyrics)
try:
mode = _normalize_mode(performance_mode)
except ValueError:
mode = "Natural / audio-led sync"
try:
song_duration = _positive_finite(song_duration_seconds, "song_duration_seconds")
excerpt_start = _positive_finite(
excerpt_start_seconds, "excerpt_start_seconds", allow_zero=True
)
excerpt_duration = _positive_finite(
excerpt_duration_seconds, "excerpt_duration_seconds"
)
minimum_confidence = _positive_finite(
minimum_alignment_confidence,
"minimum_alignment_confidence",
allow_zero=True,
)
if minimum_confidence > 1.0:
raise ValueError("minimum_alignment_confidence must be at most 1.")
except ValueError:
# Widget validation normally makes this unreachable. Preserve the
# fail-safe contract even for API-authored workflows.
song_duration = max(0.0, float(song_duration_seconds or 0.0))
excerpt_start = max(0.0, float(excerpt_start_seconds or 0.0))
excerpt_duration = max(0.0, float(excerpt_duration_seconds or 0.0))
minimum_confidence = 0.55
selected_language = str(language or "en").strip().casefold()
if selected_language not in LANGUAGES:
selected_language = "en"
report = _base_report(
performance_mode=mode,
lyrics_hash=lyrics_hash,
master_audio_sha256=str(master_audio_sha256 or ""),
song_duration=song_duration,
excerpt_start=excerpt_start,
excerpt_duration=excerpt_duration,
language=selected_language,
minimum_confidence=minimum_confidence,
model_id=None,
analysis_status="not_required" if mode != "Lyrics + lip sync" else "natural_fallback",
timing_ready=False,
)
if mode != "Lyrics + lip sync":
report["warnings"] = ["timed_lyrics_analysis_not_required_for_selected_mode"]
status = (
f"{mode}: timed lyric analysis is not required; lazy vocal separation and "
"Whisper inputs were not evaluated."
)
return _outputs(report, status)
if vocal_stem is None:
# A connected Whisper loader may already have placed its model on
# the GPU even when the separator socket is absent. Do not retain
# that allocation on this safe early-exit path.
if whisper_pipeline is not None:
_offload_model(whisper_pipeline)
return _fallback(report, "vocal_stem_missing")
if whisper_pipeline is None:
return _fallback(report, "whisper_pipeline_missing")
model_identifier = _model_id(whisper_pipeline)
report["model_id"] = model_identifier
try:
master_hash = str(master_audio_sha256 or "").strip().lower()
if not _SHA256_RE.fullmatch(master_hash):
return _fallback(report, "master_audio_sha256_invalid")
if excerpt_duration <= 0.0 or excerpt_start + excerpt_duration > song_duration + 0.05:
return _fallback(report, "excerpt_outside_song_duration")
final_hash = _waveform_sha256(final_audio)
report["diagnostics"]["final_audio_sha256"] = final_hash
if final_hash != master_hash:
return _fallback(report, "master_audio_hash_mismatch")
authored = _authored_lines(lyrics)
report["diagnostics"]["authored_non_section_line_count"] = len(authored)
if not authored:
return _fallback(report, "authored_lyrics_empty")
stem, mix, sample_rate, alignment_diagnostics = _stem_aligned_to_mix(
vocal_stem, final_audio
)
report["diagnostics"]["audio_alignment"] = alignment_diagnostics
if mix.numel() / float(sample_rate) + 0.05 < song_duration:
return _fallback(report, "final_audio_shorter_than_song_duration")
if excerpt_start + excerpt_duration > mix.numel() / float(sample_rate) + 0.05:
return _fallback(report, "excerpt_outside_audio")
similarity = _stem_full_mix_similarity(stem, mix)
report["diagnostics"]["stem_full_mix_comparison"] = similarity
if bool(similarity["near_identical_full_mix"]):
return _fallback(
report,
"vocal_stem_matches_full_mix_separator_fallback",
)
vocal_intervals, instrumental_intervals, activity_diagnostics = _vocal_activity(
stem,
mix,
sample_rate,
excerpt_start,
excerpt_duration,
)
report["vocal_intervals"] = vocal_intervals
report["instrumental_intervals"] = instrumental_intervals
report["diagnostics"]["vocal_activity"] = activity_diagnostics
if not vocal_intervals:
return _fallback(report, "no_vocal_activity_detected")
key = _cache_key(
master_hash,
excerpt_start,
excerpt_duration,
lyrics_hash,
model_identifier,
selected_language,
minimum_confidence,
)
cached = _cache_get(key)
if cached is not None:
return cached
report["diagnostics"]["cache_key_sha256"] = hashlib.sha256(
repr(key).encode("utf-8")
).hexdigest()
_prepare_model(whisper_pipeline)
phrases, transcription_diagnostics = _transcribe_active_intervals(
whisper_pipeline,
stem,
sample_rate,
excerpt_start,
vocal_intervals,
selected_language,
)
report["diagnostics"]["transcription"] = transcription_diagnostics
events, alignment_report = _align_phrases(
phrases,
authored,
excerpt_start,
song_duration,
minimum_confidence,
)
report["events"] = events
report["diagnostics"]["alignment"] = alignment_report
report["analysis_status"] = "timing_ready"
report["timing_ready"] = True
report["warnings"] = []
status = (
f"Timed lyrics ready: {len(events)} excerpt-relative event(s) aligned to "
f"authored lyrics at >= {minimum_confidence:.2f} confidence."
)
outputs = _outputs(report, status)
_cache_put(key, outputs)
return outputs
except Exception as exc: # Timing evidence is never a generation blocker.
warning = "timed_lyrics_analysis_uncertain"
message = str(exc)
if message.startswith("alignment_confidence_below_threshold"):
warning = "alignment_confidence_below_threshold"
elif "no_timestamped" in message:
warning = "whisper_returned_no_timestamped_phrases"
elif "Whisper" in message or "whisper" in message:
warning = "whisper_transcription_failed"
elif "vocal_stem" in message:
warning = "vocal_stem_invalid_or_misaligned"
report["diagnostics"]["exception_type"] = type(exc).__name__
return _fallback(report, warning, diagnostic=message)
finally:
_offload_model(whisper_pipeline)
NODE_CLASS_MAPPINGS = {
"DiffusionGemmaTimedLyricsAnalyzer": DiffusionGemmaTimedLyricsAnalyzer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DiffusionGemmaTimedLyricsAnalyzer": "DiffusionGemma Timed Lyrics Analyzer",
}
__all__ = [
"DiffusionGemmaTimedLyricsAnalyzer",
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
]