Files
ComfyUI-Prompt-Calibrator/nodes/audio_wave_segments.py
T
EthanfelandClaude Opus 4.8 ea099ccac4 Audio Wave: segments = user splits only (721 groups are markers, not segments)
Per clarification: the 721 group lines are clip markers, NOT segments. Segments come
ONLY from user split points, numbered globally from 1 — so the first split is seg1, not
seg23. 0 splits = 0 segments; N splits = N segments (seg k = point k-1 .. split k, tail
after the last split excluded). group lines still drawn (bold) + used for the group#.
JS matches (segPoints/nSegs). Empty summary now prompts to add splits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:49:46 +02:00

234 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Audio Wave Segments node for ComfyUI.
An audio node with its own upload that (via the JS widget in web/audio_wave.js)
displays the waveform, plays the clip, and lets you click segment boundaries on
the waveform and give each a note. It outputs the same waveform IMAGE + timing
`audio_summary` as Audio Prompt Guide (feed those to the judge in chat mode),
plus the AUDIO for downstream use.
Works without the JS too: leave `segments_json` as "[]" and it auto-splits, or type
per-segment notes in `notes` using the seg1:/10s:/global syntax.
"""
from __future__ import annotations
import json
import os
import re
import numpy as np
import torch
from .audio_guide import (
_rms_envelope, _tempo_beats, _snap8, _render, _summary, _attach_notes,
)
def _load_audio_file(path):
"""Load an audio file -> (waveform [C, N] float32 tensor, sample_rate).
Tries torchaudio, then soundfile, then librosa."""
try:
import torchaudio
wav, sr = torchaudio.load(path)
return wav.to(torch.float32), int(sr)
except Exception:
pass
try:
import soundfile as sf
data, sr = sf.read(path, dtype="float32", always_2d=True) # [N, C]
return torch.from_numpy(data.T.copy()), int(sr)
except Exception:
pass
import librosa
y, sr = librosa.load(path, sr=None, mono=False)
y = np.atleast_2d(y)
return torch.from_numpy(np.ascontiguousarray(y, dtype=np.float32)), int(sr)
def _segments_from_boundaries(rms_n, times, duration, fps, starts, beats):
"""Build segments from user-placed boundary start times (JS click points)."""
starts = sorted({0.0} | {round(float(s), 3) for s in starts if 0.0 < float(s) < duration})
stages = ["establish", "build", "peak", "settle"]
bounds = np.array(starts + [duration])
segs = []
for i, t0 in enumerate(starts):
t1 = starts[i + 1] if i + 1 < len(starts) else duration
mask = (times >= t0) & (times < t1)
e = float(rms_n[mask].mean()) if mask.any() else 0.0
peak_t = float(times[mask][np.argmax(rms_n[mask])]) if mask.any() else t0
dur = round(t1 - t0, 2)
label = "high" if e > 0.66 else ("medium" if e > 0.33 else "low")
stage = stages[i] if i < len(stages) else ("peak" if e > 0.6 else "settle")
segs.append({
"segment": i + 1, "start_s": round(t0, 2), "duration_s": dur,
"frames": _snap8(round(dur * fps)), "energy": label,
"energy_val": round(e, 3), "peak_s": round(peak_t, 2),
"stage_hint": stage, "note": "",
"beats_in": [round(b, 2) for b in beats if t0 <= b < t1],
})
return segs, bounds
_NO_AUDIO = "(put an audio file in ComfyUI/input)"
def _audio_files():
files = []
try:
import folder_paths
d = folder_paths.get_input_directory()
try:
files = sorted(folder_paths.filter_files_content_types(os.listdir(d), ["audio", "video"]))
except Exception:
exts = (".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac")
files = sorted(f for f in os.listdir(d) if f.lower().endswith(exts))
except Exception:
files = []
# A combo must never be empty, or ComfyUI can't build the node.
return files or [_NO_AUDIO]
def _parse_range(sel, n):
"""'' / '0' / 'all' -> (0,0) = whole clip; 'N' -> (N,N); 'A-B' -> (A,B). 1-based, clamped."""
sel = (sel or "").strip()
if not sel or sel in ("0", "all"):
return (0, 0)
m = re.match(r"^(\d+)\s*[-:]\s*(\d+)$", sel)
if m:
a, b = int(m.group(1)), int(m.group(2))
else:
try:
a = b = int(sel)
except ValueError:
return (0, 0)
if a > b:
a, b = b, a
return (max(1, min(a, n)), max(1, min(b, n)))
def _segments_grouped(rms_n, times, duration, fps, group_frames, user_starts, beats):
"""Segments come ONLY from the user's split points (the 721 group lines are just clip
markers, NOT segments). Points = [0] + splits; a segment runs between consecutive
points, so N splits -> N segments (the tail after the last split is not a segment).
Global numbering. `group` = which 721 group each segment starts in (for reference)."""
gstep = max(group_frames, 1) / max(fps, 1)
gbounds, k = [], 1
while k * gstep < duration - 1e-6: # for drawing + the group number only
gbounds.append(round(k * gstep, 3)); k += 1
pts = [0.0] + sorted({round(s, 3) for s in user_starts if 0 < s < duration - 1e-6})
stages = ["establish", "build", "peak", "settle"]
segs, bounds = [], [0.0]
for i in range(len(pts) - 1): # exclude the tail (last point -> end)
t0, t1 = pts[i], pts[i + 1]
bounds.append(round(t1, 3))
mask = (times >= t0) & (times < t1)
e = float(rms_n[mask].mean()) if mask.any() else 0.0
peak_t = float(times[mask][np.argmax(rms_n[mask])]) if mask.any() else t0
dur = round(t1 - t0, 2)
segs.append({
"segment": i + 1, "group": 1 + sum(1 for gb in gbounds if t0 >= gb - 0.02),
"start_s": round(t0, 2), "duration_s": dur, "frames": _snap8(round(dur * fps)),
"energy": "high" if e > 0.66 else ("medium" if e > 0.33 else "low"),
"energy_val": round(e, 3), "peak_s": round(peak_t, 2),
"stage_hint": stages[i] if i < len(stages) else ("peak" if e > 0.6 else "settle"),
"note": "", "beats_in": [round(b, 2) for b in beats if t0 <= b < t1],
})
return segs, np.array(bounds), gbounds
class AudioWaveSegments:
CATEGORY = "prompt_calibrator"
FUNCTION = "run"
RETURN_TYPES = ("IMAGE", "STRING", "AUDIO")
RETURN_NAMES = ("waveform_image", "audio_summary", "audio")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# Pick a file from ComfyUI/input, or use the widget's "upload" button (JS).
"audio": (_audio_files(),),
"fps": ("INT", {"default": 24, "min": 1, "max": 120}),
# Hard split every this many frames (721@24fps ~= 30.04s = one LTX clip).
# Segments live inside a group and never cross a group line.
"group_frames": ("INT", {"default": 721, "min": 1, "max": 100000}),
# "" / "0" = whole clip. "N" = only segment N. "A-B" = segments A..B —
# crops image + audio + summary to that range (for generating/skipping beats).
"segment_select": ("STRING", {"default": ""}),
"notes": ("STRING", {"default": "", "multiline": True}),
# Machine field: the JS widget writes only USER split points here (hidden).
"segments_json": ("STRING", {"default": "[]"}),
},
}
@classmethod
def IS_CHANGED(cls, audio, fps, group_frames, segment_select, notes, segments_json):
try:
import folder_paths
p = folder_paths.get_annotated_filepath(audio)
mt = os.path.getmtime(p) if os.path.isfile(p) else ""
except Exception:
mt = ""
return f"{audio}|{fps}|{group_frames}|{segment_select}|{notes}|{segments_json}|{mt}"
def run(self, audio, fps, group_frames, segment_select, notes, segments_json):
try:
import folder_paths
path = folder_paths.get_annotated_filepath(audio)
except Exception:
path = audio
if not path or not os.path.isfile(path):
blank = torch.zeros((1, 64, 512, 3))
return (blank, f"[AudioWaveSegments] audio not found: {audio}", None)
wav, sr = _load_audio_file(path) # wav: [C, N]
y = wav.mean(dim=0).cpu().numpy().astype(np.float32)
duration = len(y) / sr if sr else 0.0
rms_n, times = _rms_envelope(y, sr)
bpm, beats = _tempo_beats(y, sr)
user_starts, seg_notes = [], {} # user split points from the JS widget
try:
for i, seg in enumerate(json.loads(segments_json) if segments_json.strip() else []):
user_starts.append(float(seg.get("start_s", 0.0)))
if seg.get("note"):
seg_notes[round(float(seg.get("start_s", 0.0)), 2)] = str(seg["note"])
except Exception as e:
print(f"[AudioWaveSegments] bad segments_json ({e})")
if group_frames < 1:
group_frames = 721
segs, bounds, gbounds = _segments_grouped(rms_n, times, duration, fps, group_frames, user_starts, beats)
for s in segs: # notes attached by start time
if s["start_s"] in seg_notes:
s["note"] = seg_notes[s["start_s"]]
global_notes = _attach_notes(segs, notes) # notes-box (segN:) merges on top
a, b = _parse_range(segment_select, len(segs))
window, sel = None, None
audio_out = {"waveform": wav.unsqueeze(0), "sample_rate": sr}
if a >= 1 and segs: # crop to segment range A..B
s0 = segs[a - 1]["start_s"]
s1 = min(duration, segs[b - 1]["start_s"] + segs[b - 1]["duration_s"])
i0, i1 = int(s0 * sr), int(s1 * sr)
if i1 > i0:
audio_out = {"waveform": wav[:, i0:i1].unsqueeze(0), "sample_rate": sr}
window, sel = (s0, s1), (a, b)
total = sum(s["frames"] for s in segs[a - 1:b])
g0, g1 = segs[a - 1]["group"], segs[b - 1]["group"]
gtxt = f"group {g0}" + ("" if g0 == g1 else f"{g1}")
summary = (f"SELECTED SEGMENTS {a}-{b} of {len(segs)} ({gtxt}), {total} frames total "
f"— generate these beats.\n"
+ _summary(duration, sr, bpm, beats, segs[a - 1:b], global_notes))
else:
summary = _summary(duration, sr, bpm, beats, segs, global_notes)
image = _render(rms_n, times, duration, beats, bounds, segs,
fps=fps, group_frames=group_frames, window=window, sel=sel)
return (image, summary, audio_out)
NODE_CLASS_MAPPINGS = {"AudioWaveSegments": AudioWaveSegments}
NODE_DISPLAY_NAME_MAPPINGS = {"AudioWaveSegments": "Audio Wave + Segments"}