Audio Wave: fixed 721-frame subsegment grid + per-segment select/crop + notes box auto-fill
Pivot to the user's model: segments are a fixed grid of subsegment_frames frames (default 721 @ 24fps = one LTX clip), not arbitrary clicks. New inputs: subsegment_frames (grid size) and segment_select (0=all, N=output ONLY chunk N — crops waveform_image, AUDIO, and summary so you can generate/skip one beat at a time). _render gains window-crop + frame markers + per-segment time/frame labels. JS rewritten: draws the fixed grid, auto-fills the notes box with one segN: line per chunk (type or dblclick to note), click-to-seek, playhead + time readout. Workflows updated for the new widgets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -112,12 +112,19 @@ adds to feed the judge's `system_prompt`/`user_prompt`/`axes` sockets.)
|
|||||||
### Interactive: `Audio Wave + Segments`
|
### Interactive: `Audio Wave + Segments`
|
||||||
|
|
||||||
Same outputs, but with an in-node waveform widget: **upload** an audio clip, **play** it,
|
Same outputs, but with an in-node waveform widget: **upload** an audio clip, **play** it,
|
||||||
and **click the waveform** to place segment boundaries — click to add a split, drag to
|
click to **seek**. The audio is split into a **fixed grid of `subsegment_frames` frames**
|
||||||
move, double-click a segment for its note, shift/right-click to delete. The boundaries +
|
(default **721 @ 24fps ≈ 30.04s** — the LTX clip length), drawn as the green grid; each chunk
|
||||||
notes are saved to a hidden `segments_json` and drive the segmentation (falls back to
|
is one beat. The `notes` box **auto-fills with one `segN:` line per chunk** — type your motion
|
||||||
auto-split / the `notes` syntax if none placed). Outputs `waveform_image`, `audio_summary`,
|
note after each (or double-click a chunk on the waveform to set its note). The `waveform_image`
|
||||||
and `audio`. Needs `torchaudio`/`soundfile`/`librosa` to load the file (torchaudio usually
|
carries per-chunk labels (energy, start time, frame count).
|
||||||
ships with torch).
|
|
||||||
|
- **`subsegment_frames`** — the fixed chunk size (0 = fall back to manual boundaries in `segments_json`).
|
||||||
|
- **`segment_select`** — `0` = whole clip; `N` = output **only subsegment N**: the `waveform_image`
|
||||||
|
is cropped to that chunk, the `audio` output is cropped to it, and the summary is just that beat —
|
||||||
|
so you can generate/skip **one beat at a time**.
|
||||||
|
|
||||||
|
Outputs `waveform_image`, `audio_summary`, `audio`. Needs `torchaudio`/`soundfile`/`librosa`
|
||||||
|
to load the file (torchaudio usually ships with torch).
|
||||||
|
|
||||||
## Performance / speed
|
## Performance / speed
|
||||||
|
|
||||||
|
|||||||
+30
-13
@@ -130,27 +130,44 @@ def _segments(rms_n, times, duration, fps, max_segments, beats):
|
|||||||
return segs, bounds
|
return segs, bounds
|
||||||
|
|
||||||
|
|
||||||
def _render(rms_n, times, duration, beats, bounds, segs):
|
def _render(rms_n, times, duration, beats, bounds, segs,
|
||||||
"""Render the envelope + beats + segment boundaries to a ComfyUI IMAGE tensor."""
|
fps=None, frames_marker=0, window=None):
|
||||||
|
"""Render the envelope + beats + segment boundaries + time labels to a ComfyUI IMAGE.
|
||||||
|
window=(t0,t1) crops to that time span (for a selected segment). frames_marker draws
|
||||||
|
a green line every N frames (N/fps seconds) — LTX clip-length grid."""
|
||||||
W, H = 1024, 256
|
W, H = 1024, 256
|
||||||
img = Image.new("RGB", (W, H), (18, 18, 22))
|
img = Image.new("RGB", (W, H), (18, 18, 22))
|
||||||
d = ImageDraw.Draw(img)
|
d = ImageDraw.Draw(img)
|
||||||
dur = max(duration, 1e-6)
|
t0, t1 = window if window else (0.0, duration)
|
||||||
|
span = max(t1 - t0, 1e-6)
|
||||||
|
|
||||||
def X(t):
|
def X(t):
|
||||||
return int(max(0, min(W - 1, t / dur * (W - 1))))
|
return int(max(0, min(W - 1, (t - t0) / span * (W - 1))))
|
||||||
|
|
||||||
pts = [(0, H)] + [(X(times[i]), H - int(rms_n[i] * (H - 26))) for i in range(len(rms_n))] + [(W - 1, H)]
|
pts = [(0, H)] + [(X(times[i]), H - int(rms_n[i] * (H - 30)))
|
||||||
d.polygon(pts, fill=(60, 140, 220))
|
for i in range(len(rms_n)) if t0 <= times[i] <= t1] + [(W - 1, H)]
|
||||||
|
if len(pts) > 2:
|
||||||
|
d.polygon(pts, fill=(60, 140, 220))
|
||||||
|
if fps and frames_marker: # frame grid (green)
|
||||||
|
step = frames_marker / fps
|
||||||
|
k = 1
|
||||||
|
while k * step < duration + 1e-6:
|
||||||
|
mt = k * step
|
||||||
|
if t0 <= mt <= t1:
|
||||||
|
d.line([(X(mt), 0), (X(mt), H)], fill=(70, 200, 120), width=1)
|
||||||
|
d.text((X(mt) + 2, H - 13), f"{frames_marker * k}f", fill=(70, 200, 120))
|
||||||
|
k += 1
|
||||||
for b in beats: # beat markers (orange)
|
for b in beats: # beat markers (orange)
|
||||||
d.line([(X(b), 0), (X(b), H)], fill=(230, 110, 60), width=1)
|
if t0 <= b <= t1:
|
||||||
for bd in bounds: # segment boundaries (white)
|
d.line([(X(b), 0), (X(b), H)], fill=(230, 110, 60), width=1)
|
||||||
d.line([(X(bd), 0), (X(bd), H)], fill=(240, 240, 240), width=1)
|
for s in segs: # boundaries + label + time + note
|
||||||
for s in segs:
|
if not (t0 <= s["start_s"] <= t1):
|
||||||
x = X(s["start_s"]) + 4
|
continue
|
||||||
d.text((x, 4), f"S{s['segment']} {s['energy']}", fill=(255, 255, 255))
|
x = X(s["start_s"])
|
||||||
|
d.line([(x, 0), (x, H)], fill=(240, 240, 240), width=1)
|
||||||
|
d.text((x + 4, 4), f"S{s['segment']} {s['energy']} {s['start_s']}s/{s['frames']}f", fill=(255, 255, 255))
|
||||||
if s.get("note"): # per-segment note (amber)
|
if s.get("note"): # per-segment note (amber)
|
||||||
d.text((x, 18), s["note"][:30], fill=(255, 210, 110))
|
d.text((x + 4, 18), s["note"][:30], fill=(255, 210, 110))
|
||||||
arr = np.asarray(img, dtype=np.float32) / 255.0
|
arr = np.asarray(img, dtype=np.float32) / 255.0
|
||||||
return torch.from_numpy(arr)[None, ...] # [1, H, W, 3]
|
return torch.from_numpy(arr)[None, ...] # [1, H, W, 3]
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,33 @@ def _audio_files():
|
|||||||
return files or [_NO_AUDIO]
|
return files or [_NO_AUDIO]
|
||||||
|
|
||||||
|
|
||||||
|
def _segments_fixed(rms_n, times, duration, fps, sub_frames, beats):
|
||||||
|
"""Split into fixed chunks of `sub_frames` frames (the LTX clip length). The last
|
||||||
|
chunk gets whatever's left (snapped to 8n+1)."""
|
||||||
|
step = max(sub_frames, 1) / max(fps, 1) # seconds per chunk
|
||||||
|
n = max(1, int(np.ceil(duration / step - 1e-6)))
|
||||||
|
stages = ["establish", "build", "peak", "settle"]
|
||||||
|
segs, bounds = [], [0.0]
|
||||||
|
for i in range(n):
|
||||||
|
t0 = i * step
|
||||||
|
t1 = min(duration, (i + 1) * step)
|
||||||
|
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)
|
||||||
|
frames = sub_frames if i < n - 1 else _snap8(round(dur * fps))
|
||||||
|
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": frames,
|
||||||
|
"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, np.array(bounds)
|
||||||
|
|
||||||
|
|
||||||
class AudioWaveSegments:
|
class AudioWaveSegments:
|
||||||
CATEGORY = "prompt_calibrator"
|
CATEGORY = "prompt_calibrator"
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
@@ -101,25 +128,29 @@ class AudioWaveSegments:
|
|||||||
# Pick a file from ComfyUI/input, or use the widget's "upload" button (JS).
|
# Pick a file from ComfyUI/input, or use the widget's "upload" button (JS).
|
||||||
"audio": (_audio_files(),),
|
"audio": (_audio_files(),),
|
||||||
"fps": ("INT", {"default": 24, "min": 1, "max": 120}),
|
"fps": ("INT", {"default": 24, "min": 1, "max": 120}),
|
||||||
"max_segments": ("INT", {"default": 6, "min": 3, "max": 12}),
|
# Fixed LTX clip length. Segments = chunks of this many frames (721@24fps
|
||||||
|
# ~= 30.04s). This is also the waveform grid. Set 0 to use segments_json.
|
||||||
|
"subsegment_frames": ("INT", {"default": 721, "min": 0, "max": 100000}),
|
||||||
|
# 0 = whole clip. N = output ONLY subsegment N (crops image + audio + summary),
|
||||||
|
# so you can generate/skip one beat at a time.
|
||||||
|
"segment_select": ("INT", {"default": 0, "min": 0, "max": 999}),
|
||||||
"notes": ("STRING", {"default": "", "multiline": True}),
|
"notes": ("STRING", {"default": "", "multiline": True}),
|
||||||
# Written by the JS waveform widget: [{"start_s": 0.0, "note": "..."}, ...].
|
# Machine field: per-chunk notes/boundaries from the JS widget (hidden).
|
||||||
# Leave as "[]" to auto-split. Also editable by hand.
|
|
||||||
"segments_json": ("STRING", {"default": "[]"}),
|
"segments_json": ("STRING", {"default": "[]"}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def IS_CHANGED(cls, audio, fps, max_segments, notes, segments_json):
|
def IS_CHANGED(cls, audio, fps, subsegment_frames, segment_select, notes, segments_json):
|
||||||
try:
|
try:
|
||||||
import folder_paths
|
import folder_paths
|
||||||
p = folder_paths.get_annotated_filepath(audio)
|
p = folder_paths.get_annotated_filepath(audio)
|
||||||
mt = os.path.getmtime(p) if os.path.isfile(p) else ""
|
mt = os.path.getmtime(p) if os.path.isfile(p) else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
mt = ""
|
mt = ""
|
||||||
return f"{audio}|{fps}|{max_segments}|{notes}|{segments_json}|{mt}"
|
return f"{audio}|{fps}|{subsegment_frames}|{segment_select}|{notes}|{segments_json}|{mt}"
|
||||||
|
|
||||||
def run(self, audio, fps, max_segments, notes, segments_json):
|
def run(self, audio, fps, subsegment_frames, segment_select, notes, segments_json):
|
||||||
try:
|
try:
|
||||||
import folder_paths
|
import folder_paths
|
||||||
path = folder_paths.get_annotated_filepath(audio)
|
path = folder_paths.get_annotated_filepath(audio)
|
||||||
@@ -135,28 +166,44 @@ class AudioWaveSegments:
|
|||||||
rms_n, times = _rms_envelope(y, sr)
|
rms_n, times = _rms_envelope(y, sr)
|
||||||
bpm, beats = _tempo_beats(y, sr)
|
bpm, beats = _tempo_beats(y, sr)
|
||||||
|
|
||||||
# User boundaries from the JS widget, else auto-split.
|
if subsegment_frames > 0: # fixed LTX-clip grid
|
||||||
starts, seg_notes = [], {}
|
segs, bounds = _segments_fixed(rms_n, times, duration, fps, subsegment_frames, beats)
|
||||||
try:
|
else: # manual boundaries from the JS widget
|
||||||
data = json.loads(segments_json) if segments_json.strip() else []
|
starts = []
|
||||||
for i, seg in enumerate(data):
|
try:
|
||||||
starts.append(float(seg.get("start_s", 0.0)))
|
for seg in (json.loads(segments_json) if segments_json.strip() else []):
|
||||||
seg_notes[i + 1] = str(seg.get("note", "") or "")
|
starts.append(float(seg.get("start_s", 0.0)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[AudioWaveSegments] bad segments_json ({e}); auto-splitting.")
|
print(f"[AudioWaveSegments] bad segments_json ({e})")
|
||||||
data = []
|
|
||||||
|
|
||||||
if data:
|
|
||||||
segs, bounds = _segments_from_boundaries(rms_n, times, duration, fps, starts, beats)
|
segs, bounds = _segments_from_boundaries(rms_n, times, duration, fps, starts, beats)
|
||||||
for s in segs: # notes placed on the waveform
|
|
||||||
s["note"] = seg_notes.get(s["segment"], "")
|
|
||||||
else:
|
|
||||||
segs, bounds = _segments(rms_n, times, duration, fps, max_segments, beats)
|
|
||||||
|
|
||||||
global_notes = _attach_notes(segs, notes) # merge the text-box notes on top
|
# per-chunk notes from segments_json (JS) then the notes-box (segN: syntax) on top.
|
||||||
image = _render(rms_n, times, duration, beats, bounds, segs)
|
try:
|
||||||
summary = _summary(duration, sr, bpm, beats, segs, global_notes)
|
for i, seg in enumerate(json.loads(segments_json) if segments_json.strip() else []):
|
||||||
audio_out = {"waveform": wav.unsqueeze(0), "sample_rate": sr} # [1, C, N]
|
if i < len(segs) and seg.get("note"):
|
||||||
|
segs[i]["note"] = str(seg["note"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
global_notes = _attach_notes(segs, notes)
|
||||||
|
|
||||||
|
window, render_segs = None, segs
|
||||||
|
audio_out = {"waveform": wav.unsqueeze(0), "sample_rate": sr}
|
||||||
|
sel = int(segment_select)
|
||||||
|
if 1 <= sel <= len(segs): # crop to one subsegment
|
||||||
|
seg = segs[sel - 1]
|
||||||
|
s0 = seg["start_s"]
|
||||||
|
s1 = min(duration, s0 + seg["duration_s"])
|
||||||
|
a, b = int(s0 * sr), int(s1 * sr)
|
||||||
|
if b > a:
|
||||||
|
audio_out = {"waveform": wav[:, a:b].unsqueeze(0), "sample_rate": sr}
|
||||||
|
window, render_segs = (s0, s1), [seg]
|
||||||
|
summary = (f"SELECTED SUBSEGMENT {sel} of {len(segs)} — generate ONLY this beat.\n"
|
||||||
|
+ _summary(duration, sr, bpm, beats, [seg], global_notes))
|
||||||
|
else:
|
||||||
|
summary = _summary(duration, sr, bpm, beats, segs, global_notes)
|
||||||
|
|
||||||
|
image = _render(rms_n, times, duration, beats, bounds, render_segs,
|
||||||
|
fps=fps, frames_marker=subsegment_frames, window=window)
|
||||||
return (image, summary, audio_out)
|
return (image, summary, audio_out)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+78
-60
@@ -1,5 +1,5 @@
|
|||||||
// Audio Wave + Segments — waveform display + playback + click-to-segment.
|
// Audio Wave + Segments — waveform + playback, a fixed subsegment grid (721 frames @ fps),
|
||||||
// Writes the segment boundaries/notes into the node's hidden `segments_json` widget.
|
// per-chunk notes auto-filled into the `notes` box, playhead + click-to-seek.
|
||||||
// First cut: open the browser console for [audiowave] logs if something misbehaves.
|
// First cut: open the browser console for [audiowave] logs if something misbehaves.
|
||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
@@ -13,20 +13,16 @@ function computePeaks(buf, n) {
|
|||||||
let max = 1e-6;
|
let max = 1e-6;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
let m = 0;
|
let m = 0;
|
||||||
const start = i * block;
|
const s = i * block;
|
||||||
for (let j = 0; j < block && start + j < data.length; j++) {
|
for (let j = 0; j < block && s + j < data.length; j++) { const v = Math.abs(data[s + j]); if (v > m) m = v; }
|
||||||
const v = Math.abs(data[start + j]);
|
peaks[i] = m; if (m > max) max = m;
|
||||||
if (v > m) m = v;
|
|
||||||
}
|
|
||||||
peaks[i] = m;
|
|
||||||
if (m > max) max = m;
|
|
||||||
}
|
}
|
||||||
for (let i = 0; i < n; i++) peaks[i] /= max;
|
for (let i = 0; i < n; i++) peaks[i] /= max;
|
||||||
return peaks;
|
return peaks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupWave(node) {
|
function setupWave(node) {
|
||||||
const st = { duration: 0, peaks: null, boundaries: [], notes: {}, audio: new Audio(), playing: false };
|
const st = { duration: 0, peaks: null, audio: new Audio(), playing: false };
|
||||||
node._wave = st;
|
node._wave = st;
|
||||||
|
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -34,29 +30,60 @@ function setupWave(node) {
|
|||||||
const bar = document.createElement("div");
|
const bar = document.createElement("div");
|
||||||
bar.style.cssText = "display:flex;gap:6px;align-items:center;font-size:10px;color:#bbb;flex-wrap:wrap;";
|
bar.style.cssText = "display:flex;gap:6px;align-items:center;font-size:10px;color:#bbb;flex-wrap:wrap;";
|
||||||
const mk = (t) => { const b = document.createElement("button"); b.textContent = t; b.style.cssText = "font-size:10px;padding:1px 6px;"; return b; };
|
const mk = (t) => { const b = document.createElement("button"); b.textContent = t; b.style.cssText = "font-size:10px;padding:1px 6px;"; return b; };
|
||||||
const playBtn = mk("▶ play"), clearBtn = mk("clear"), upBtn = mk("upload");
|
const playBtn = mk("▶ play"), upBtn = mk("upload");
|
||||||
const hint = document.createElement("span");
|
const readout = document.createElement("span"); readout.textContent = "0.00 / 0.00s";
|
||||||
hint.textContent = "click=add split · drag=move · dblclick=note · shift/right-click=delete";
|
const hint = document.createElement("span"); hint.textContent = "click=seek · dblclick a chunk=note";
|
||||||
bar.append(playBtn, clearBtn, upBtn, hint);
|
bar.append(playBtn, upBtn, readout, hint);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 640; canvas.height = 150;
|
canvas.width = 640; canvas.height = 160;
|
||||||
canvas.style.cssText = "width:100%;height:150px;background:#141418;border-radius:4px;cursor:crosshair;";
|
canvas.style.cssText = "width:100%;height:160px;background:#141418;border-radius:4px;cursor:pointer;";
|
||||||
wrap.append(bar, canvas);
|
wrap.append(bar, canvas);
|
||||||
node.addDOMWidget("wave", "wave", wrap, { serialize: false });
|
node.addDOMWidget("wave", "wave", wrap, { serialize: false });
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
// Hide the raw segments_json widget — it's machine data driven by the waveform.
|
const sjw = getW(node, "segments_json"); // machine field — hide it
|
||||||
const sjw = getW(node, "segments_json");
|
|
||||||
if (sjw) { sjw.hidden = true; sjw.computeSize = () => [0, -4]; }
|
if (sjw) { sjw.hidden = true; sjw.computeSize = () => [0, -4]; }
|
||||||
|
|
||||||
|
const fps = () => Math.max(1, Number(getW(node, "fps")?.value) || 24);
|
||||||
|
const subFrames = () => Math.max(0, Number(getW(node, "subsegment_frames")?.value) || 0);
|
||||||
const t2x = (t) => (st.duration ? (t / st.duration) * canvas.width : 0);
|
const t2x = (t) => (st.duration ? (t / st.duration) * canvas.width : 0);
|
||||||
const x2t = (x) => (st.duration ? (x / canvas.width) * st.duration : 0);
|
const x2t = (x) => (st.duration ? (x / canvas.width) * st.duration : 0);
|
||||||
const starts = () => [0, ...st.boundaries.slice().sort((a, b) => a - b)];
|
|
||||||
|
|
||||||
function serialize() {
|
function chunkStarts() {
|
||||||
const segs = starts().map((s, i) => ({ start_s: Math.round(s * 100) / 100, note: st.notes[i] || "" }));
|
const step = subFrames() / fps();
|
||||||
const w = getW(node, "segments_json");
|
const arr = [];
|
||||||
if (w) { w.value = JSON.stringify(segs); w.callback?.(w.value); }
|
if (!st.duration || !step) return [0];
|
||||||
|
for (let t = 0; t < st.duration - 1e-6; t += step) arr.push(t);
|
||||||
|
return arr.length ? arr : [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNotes(txt) {
|
||||||
|
const seg = {}, glob = [];
|
||||||
|
(txt || "").split("\n").forEach((l) => {
|
||||||
|
const m = l.match(/^\s*(?:seg(?:ment)?|s)?\s*(\d+)\s*[:)]\s*(.*)$/i);
|
||||||
|
if (m) seg[parseInt(m[1])] = m[2]; else if (l.trim()) glob.push(l);
|
||||||
|
});
|
||||||
|
return { seg, glob };
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNotesBox() { // one "segN:" line per chunk, preserve notes
|
||||||
|
const nw = getW(node, "notes"); if (!nw) return;
|
||||||
|
const { seg, glob } = parseNotes(nw.value);
|
||||||
|
const n = chunkStarts().length;
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 1; i <= n; i++) lines.push(`seg${i}: ${seg[i] !== undefined ? seg[i] : ""}`);
|
||||||
|
const val = lines.concat(glob).join("\n");
|
||||||
|
if (nw.value !== val) { nw.value = val; nw.callback?.(val); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChunkNote(i, val) {
|
||||||
|
const nw = getW(node, "notes"); if (!nw) return;
|
||||||
|
const { seg, glob } = parseNotes(nw.value);
|
||||||
|
seg[i] = val;
|
||||||
|
const n = chunkStarts().length;
|
||||||
|
const lines = [];
|
||||||
|
for (let k = 1; k <= n; k++) lines.push(`seg${k}: ${seg[k] !== undefined ? seg[k] : ""}`);
|
||||||
|
nw.value = lines.concat(glob).join("\n"); nw.callback?.(nw.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
@@ -64,57 +91,49 @@ function setupWave(node) {
|
|||||||
if (st.peaks) {
|
if (st.peaks) {
|
||||||
ctx.fillStyle = "#3c8cdc";
|
ctx.fillStyle = "#3c8cdc";
|
||||||
const n = st.peaks.length, bw = canvas.width / n;
|
const n = st.peaks.length, bw = canvas.width / n;
|
||||||
for (let i = 0; i < n; i++) { const h = st.peaks[i] * (canvas.height - 26); ctx.fillRect(i * bw, canvas.height - h, Math.max(1, bw), h); }
|
for (let i = 0; i < n; i++) { const h = st.peaks[i] * (canvas.height - 30); ctx.fillRect(i * bw, canvas.height - h, Math.max(1, bw), h); }
|
||||||
}
|
}
|
||||||
ctx.font = "10px monospace";
|
ctx.font = "10px monospace";
|
||||||
starts().forEach((s, i) => {
|
const { seg } = parseNotes(getW(node, "notes")?.value);
|
||||||
|
const starts = chunkStarts();
|
||||||
|
starts.forEach((s, i) => {
|
||||||
const x = t2x(s);
|
const x = t2x(s);
|
||||||
ctx.strokeStyle = "#eee"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
ctx.strokeStyle = "#7ec8a0"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||||
ctx.fillStyle = "#fff"; ctx.fillText("S" + (i + 1), x + 3, 11);
|
ctx.fillStyle = "#fff"; ctx.fillText(`S${i + 1} ${s.toFixed(1)}s`, x + 3, 11);
|
||||||
if (st.notes[i]) { ctx.fillStyle = "#ffd27a"; ctx.fillText(st.notes[i].slice(0, 24), x + 3, 23); }
|
if (seg[i + 1]) { ctx.fillStyle = "#ffd27a"; ctx.fillText(seg[i + 1].slice(0, 22), x + 3, 23); }
|
||||||
});
|
});
|
||||||
if (st.playing) { const x = t2x(st.audio.currentTime); ctx.strokeStyle = "#ff5a3c"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
|
if (st.playing || st.audio.currentTime) {
|
||||||
|
const x = t2x(st.audio.currentTime);
|
||||||
|
ctx.strokeStyle = "#ff5a3c"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||||
|
}
|
||||||
|
readout.textContent = `${(st.audio.currentTime || 0).toFixed(2)} / ${st.duration.toFixed(2)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(name) {
|
async function loadFile(name) {
|
||||||
if (!name) return;
|
if (!name || name.startsWith("(")) return;
|
||||||
const url = `/view?filename=${encodeURIComponent(name)}&type=input&subfolder=`;
|
const url = `/view?filename=${encodeURIComponent(name)}&type=input&subfolder=`;
|
||||||
try {
|
try {
|
||||||
const ab = await (await fetch(url)).arrayBuffer();
|
const ab = await (await fetch(url)).arrayBuffer();
|
||||||
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
const ac = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
const buf = await ac.decodeAudioData(ab.slice(0));
|
const buf = await ac.decodeAudioData(ab.slice(0));
|
||||||
st.duration = buf.duration; st.peaks = computePeaks(buf, canvas.width);
|
st.duration = buf.duration; st.peaks = computePeaks(buf, canvas.width);
|
||||||
st.audio.src = url; draw();
|
st.audio.src = url; syncNotesBox(); draw();
|
||||||
} catch (e) { console.error("[audiowave] could not load/decode", name, e); }
|
} catch (e) { console.error("[audiowave] could not load/decode", name, e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const tol = () => x2t(6);
|
canvas.addEventListener("mousedown", (e) => { // click = seek
|
||||||
const nearBoundary = (t) => { let bi = -1, bd = 1e9; st.boundaries.forEach((b, i) => { const d = Math.abs(b - t); if (d < tol() && d < bd) { bd = d; bi = i; } }); return bi; };
|
|
||||||
let dragIdx = -1;
|
|
||||||
|
|
||||||
canvas.addEventListener("mousedown", (e) => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
const t = x2t(((e.clientX - r.left) / r.width) * canvas.width);
|
st.audio.currentTime = Math.max(0, Math.min(st.duration, x2t(((e.clientX - r.left) / r.width) * canvas.width)));
|
||||||
if (e.button === 2 || e.shiftKey) { const bi = nearBoundary(t); if (bi >= 0) { st.boundaries.splice(bi, 1); serialize(); draw(); } e.preventDefault(); return; }
|
|
||||||
const bi = nearBoundary(t);
|
|
||||||
if (bi >= 0) { dragIdx = bi; }
|
|
||||||
else { st.boundaries.push(t); serialize(); draw(); }
|
|
||||||
});
|
|
||||||
canvas.addEventListener("mousemove", (e) => {
|
|
||||||
if (dragIdx < 0) return;
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
st.boundaries[dragIdx] = Math.max(0.01, Math.min(st.duration - 0.01, x2t(((e.clientX - r.left) / r.width) * canvas.width)));
|
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
window.addEventListener("mouseup", () => { if (dragIdx >= 0) { serialize(); dragIdx = -1; draw(); } });
|
canvas.addEventListener("dblclick", (e) => { // dblclick a chunk = edit its note
|
||||||
canvas.addEventListener("dblclick", (e) => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
const t = x2t(((e.clientX - r.left) / r.width) * canvas.width);
|
const t = x2t(((e.clientX - r.left) / r.width) * canvas.width);
|
||||||
const ss = starts(); let si = 0; for (let i = 0; i < ss.length; i++) if (t >= ss[i]) si = i;
|
const starts = chunkStarts(); let i = 0; for (let k = 0; k < starts.length; k++) if (t >= starts[k]) i = k;
|
||||||
const val = window.prompt(`Note for segment ${si + 1}:`, st.notes[si] || "");
|
const { seg } = parseNotes(getW(node, "notes")?.value);
|
||||||
if (val !== null) { st.notes[si] = val; serialize(); draw(); }
|
const val = window.prompt(`Note for subsegment ${i + 1}:`, seg[i + 1] || "");
|
||||||
|
if (val !== null) { setChunkNote(i + 1, val); draw(); }
|
||||||
});
|
});
|
||||||
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
||||||
|
|
||||||
const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); };
|
const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); };
|
||||||
playBtn.onclick = () => {
|
playBtn.onclick = () => {
|
||||||
@@ -122,7 +141,6 @@ function setupWave(node) {
|
|||||||
else { st.audio.pause(); st.playing = false; playBtn.textContent = "▶ play"; }
|
else { st.audio.pause(); st.playing = false; playBtn.textContent = "▶ play"; }
|
||||||
};
|
};
|
||||||
st.audio.onended = () => { st.playing = false; playBtn.textContent = "▶ play"; draw(); };
|
st.audio.onended = () => { st.playing = false; playBtn.textContent = "▶ play"; draw(); };
|
||||||
clearBtn.onclick = () => { st.boundaries = []; st.notes = {}; serialize(); draw(); };
|
|
||||||
|
|
||||||
upBtn.onclick = () => {
|
upBtn.onclick = () => {
|
||||||
const inp = document.createElement("input"); inp.type = "file"; inp.accept = "audio/*";
|
const inp = document.createElement("input"); inp.type = "file"; inp.accept = "audio/*";
|
||||||
@@ -132,19 +150,19 @@ function setupWave(node) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.fetchApi("/upload/image", { method: "POST", body: fd });
|
const res = await api.fetchApi("/upload/image", { method: "POST", body: fd });
|
||||||
const j = await res.json(); const name = j.name || f.name;
|
const j = await res.json(); const name = j.name || f.name;
|
||||||
const w = getW(node, "audio");
|
const w = getW(node, "audio"); if (w) { if (!w.options.values.includes(name)) w.options.values.push(name); w.value = name; }
|
||||||
if (w) { if (!w.options.values.includes(name)) w.options.values.push(name); w.value = name; }
|
|
||||||
await loadFile(name);
|
await loadFile(name);
|
||||||
} catch (e) { console.error("[audiowave] upload failed (drop the file in ComfyUI/input instead)", e); }
|
} catch (e) { console.error("[audiowave] upload failed (drop the file in ComfyUI/input instead)", e); }
|
||||||
};
|
};
|
||||||
inp.click();
|
inp.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// react to the audio combo changing + initial load
|
// re-grid + re-fill notes when the audio, fps or subsegment_frames change
|
||||||
const aw = getW(node, "audio");
|
const hook = (name, fn) => { const w = getW(node, name); if (w) { const cb = w.callback; w.callback = function () { const r = cb ? cb.apply(this, arguments) : undefined; fn(); return r; }; } };
|
||||||
if (aw) { const cb = aw.callback; aw.callback = function () { const r = cb ? cb.apply(this, arguments) : undefined; loadFile(aw.value); return r; }; if (aw.value) loadFile(aw.value); }
|
hook("audio", () => loadFile(getW(node, "audio")?.value));
|
||||||
// restore boundaries/notes from a reloaded workflow
|
hook("fps", () => { syncNotesBox(); draw(); });
|
||||||
try { const sj = getW(node, "segments_json"); if (sj && sj.value) { const arr = JSON.parse(sj.value); st.boundaries = arr.slice(1).map((s) => s.start_s); arr.forEach((s, i) => { if (s.note) st.notes[i] = s.note; }); } } catch (e) { /* ignore */ }
|
hook("subsegment_frames", () => { syncNotesBox(); draw(); });
|
||||||
|
const aw = getW(node, "audio"); if (aw?.value) loadFile(aw.value);
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,8 @@
|
|||||||
"widgets_values": [
|
"widgets_values": [
|
||||||
"audio.wav",
|
"audio.wav",
|
||||||
24,
|
24,
|
||||||
6,
|
721,
|
||||||
|
0,
|
||||||
"",
|
"",
|
||||||
"[]"
|
"[]"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"audio": "audio.wav",
|
"audio": "audio.wav",
|
||||||
"fps": 24,
|
"fps": 24,
|
||||||
"max_segments": 6,
|
"subsegment_frames": 721,
|
||||||
|
"segment_select": 0,
|
||||||
"notes": "",
|
"notes": "",
|
||||||
"segments_json": "[]"
|
"segments_json": "[]"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user