Audio Wave: correct model — 721 groups are hard splits, segments inside; range select; mirrored render; fix reload explosion

Rebuilt around the real model: group_frames (721) are HARD splits; segments = user
splits UNION the group lines, so a segment never crosses a group boundary (last segment
in a group ends exactly on frame 721). segment_select is now a RANGE string ('A-B' /
'N' / '' = all) that crops waveform_image + audio + summary to segments A..B. Render
rewritten: mirrored waveform (uses top+bottom), bold group grid, thin segment lines,
labels along the top and notes along the bottom, selected range shaded. JS: guards
frame values (0 -> default, fixing the 400+-segments-on-reload explosion), writes only
USER splits to segments_json, dblclick=add split / shift-click=remove / click=seek.
group number fixed (boundary rounding). Workflows + README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 23:35:48 +02:00
co-authored by Claude Opus 4.8
parent 29c48a9115
commit 488afa0284
6 changed files with 205 additions and 181 deletions
+11 -10
View File
@@ -111,17 +111,18 @@ 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, An in-node **mirrored waveform** widget: **upload** an audio clip, **play** it, **click to seek**.
click to **seek**. The audio is split into a **fixed grid of `subsegment_frames` frames**
(default **721 @ 24fps ≈ 30.04s** — the LTX clip length), drawn as the green grid; each chunk
is one beat. The `notes` box **auto-fills with one `segN:` line per chunk** — type your motion
note after each (or double-click a chunk on the waveform to set its note). The `waveform_image`
carries per-chunk labels (energy, start time, frame count).
- **`subsegment_frames`** — the fixed chunk size (0 = fall back to manual boundaries in `segments_json`). - **`group_frames`** (default **721 @ 24fps ≈ 30.04s** = one LTX clip) is the **hard split** —
- **`segment_select`** — `0` = whole clip; `N` = output **only subsegment N**: the `waveform_image` bold grid lines every `group_frames`. Segments never cross a group line, so the last segment
is cropped to that chunk, the `audio` output is cropped to it, and the summary is just that beat — in a group ends exactly on the boundary (e.g. frame 721).
so you can generate/skip **one beat at a time**. - **Segments live inside groups.** By default each group is one segment; **double-click the
waveform to add a finer split** inside a group (shift-click a split to remove it). The `notes`
box **auto-fills with one `segN:` line per segment** — type your motion note after each.
- **`segment_select`** — `""`/`0` = whole clip; `"N"` = only segment N; **`"A-B"` = segments A..B**.
The `waveform_image`, the `audio` output, and the summary are all **cropped to that range**, so
you can generate/skip beats a range at a time (e.g. `6-12`). Labels sit along the top, per-segment
notes along the bottom; the selected range is shaded.
Outputs `waveform_image`, `audio_summary`, `audio`. Needs `torchaudio`/`soundfile`/`librosa` Outputs `waveform_image`, `audio_summary`, `audio`. Needs `torchaudio`/`soundfile`/`librosa`
to load the file (torchaudio usually ships with torch). to load the file (torchaudio usually ships with torch).
+29 -22
View File
@@ -131,43 +131,50 @@ def _segments(rms_n, times, duration, fps, max_segments, beats):
def _render(rms_n, times, duration, beats, bounds, segs, def _render(rms_n, times, duration, beats, bounds, segs,
fps=None, frames_marker=0, window=None): fps=None, group_frames=0, frames_marker=0, window=None, sel=None):
"""Render the envelope + beats + segment boundaries + time labels to a ComfyUI IMAGE. """Render a mirrored waveform + the 721-frame group grid (bold) + segment lines (thin),
window=(t0,t1) crops to that time span (for a selected segment). frames_marker draws labels along the top and per-segment notes along the bottom. window=(t0,t1) crops to a
a green line every N frames (N/fps seconds) — LTX clip-length grid.""" range; sel=(a,b) shades segments a..b."""
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)
t0, t1 = window if window else (0.0, duration) t0, t1 = window if window else (0.0, duration)
span = max(t1 - t0, 1e-6) span = max(t1 - t0, 1e-6)
mid = H // 2
def X(t): def X(t):
return int(max(0, min(W - 1, (t - t0) / span * (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 - 30))) if sel and not window: # shade the selected segment range
for i in range(len(rms_n)) if t0 <= times[i] <= t1] + [(W - 1, H)] chosen = [s for s in segs if sel[0] <= s["segment"] <= sel[1]]
if len(pts) > 2: if chosen:
d.polygon(pts, fill=(60, 140, 220)) xa = X(chosen[0]["start_s"])
if fps and frames_marker: # frame grid (green) xb = X(chosen[-1]["start_s"] + chosen[-1]["duration_s"])
step = frames_marker / fps d.rectangle([xa, 0, xb, H], fill=(38, 54, 82))
amp = H * 0.44 # mirrored waveform around the centre line
for i in range(len(rms_n)):
if t0 <= times[i] <= t1:
x = X(times[i]); h = int(rms_n[i] * amp)
d.line([(x, mid - h), (x, mid + h)], fill=(60, 140, 220))
if fps and group_frames: # bold 721-frame group grid
gstep = group_frames / fps
k = 1 k = 1
while k * step < duration + 1e-6: while k * gstep < duration + 1e-6:
mt = k * step gt = k * gstep
if t0 <= mt <= t1: if t0 <= gt <= t1:
d.line([(X(mt), 0), (X(mt), H)], fill=(70, 200, 120), width=1) d.line([(X(gt), 0), (X(gt), H)], fill=(235, 235, 242), width=2)
d.text((X(mt) + 2, H - 13), f"{frames_marker * k}f", fill=(70, 200, 120))
k += 1 k += 1
for b in beats: # beat markers (orange) for b in beats: # beat ticks (faint, centre band)
if t0 <= b <= t1: if t0 <= b <= t1:
d.line([(X(b), 0), (X(b), H)], fill=(230, 110, 60), width=1) d.line([(X(b), mid - 4), (X(b), mid + 4)], fill=(150, 90, 60), width=1)
for s in segs: # boundaries + label + time + note for s in segs: # segment line + label top + note bottom
if not (t0 <= s["start_s"] <= t1): if not (t0 <= s["start_s"] <= t1):
continue continue
x = X(s["start_s"]) x = X(s["start_s"])
d.line([(x, 0), (x, H)], fill=(240, 240, 240), width=1) d.line([(x, 0), (x, H)], fill=(120, 190, 150), width=1)
d.text((x + 4, 4), f"S{s['segment']} {s['energy']} {s['start_s']}s/{s['frames']}f", fill=(255, 255, 255)) d.text((x + 3, 3), f"S{s['segment']} {s['start_s']}s/{s['frames']}f", fill=(240, 240, 240))
if s.get("note"): # per-segment note (amber) if s.get("note"):
d.text((x + 4, 18), s["note"][:30], fill=(255, 210, 110)) d.text((x + 3, H - 13), s["note"][:28], 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]
+80 -58
View File
@@ -15,12 +15,13 @@ from __future__ import annotations
import json import json
import os import os
import re
import numpy as np import numpy as np
import torch import torch
from .audio_guide import ( from .audio_guide import (
_rms_envelope, _tempo_beats, _snap8, _segments, _render, _summary, _attach_notes, _rms_envelope, _tempo_beats, _snap8, _render, _summary, _attach_notes,
) )
@@ -88,31 +89,52 @@ def _audio_files():
return files or [_NO_AUDIO] return files or [_NO_AUDIO]
def _segments_fixed(rms_n, times, duration, fps, sub_frames, beats): def _parse_range(sel, n):
"""Split into fixed chunks of `sub_frames` frames (the LTX clip length). The last """'' / '0' / 'all' -> (0,0) = whole clip; 'N' -> (N,N); 'A-B' -> (A,B). 1-based, clamped."""
chunk gets whatever's left (snapped to 8n+1).""" sel = (sel or "").strip()
step = max(sub_frames, 1) / max(fps, 1) # seconds per chunk if not sel or sel in ("0", "all"):
n = max(1, int(np.ceil(duration / step - 1e-6))) 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 = user split points UNION the fixed group boundaries (every group_frames
frames). The group lines are hard splits, so a segment never crosses one — the last
segment in a group ends exactly on the group boundary (e.g. frame 721)."""
gstep = max(group_frames, 1) / max(fps, 1)
gbounds, k = [], 1
while k * gstep < duration - 1e-6:
gbounds.append(round(k * gstep, 3)); k += 1
allb = sorted({round(b, 3) for b in list(user_starts) + gbounds if 0 < b < duration - 1e-6})
starts = [0.0] + allb
stages = ["establish", "build", "peak", "settle"] stages = ["establish", "build", "peak", "settle"]
segs, bounds = [], [0.0] segs, bounds = [], [0.0]
for i in range(n): for i, t0 in enumerate(starts):
t0 = i * step t1 = starts[i + 1] if i + 1 < len(starts) else duration
t1 = min(duration, (i + 1) * step)
bounds.append(round(t1, 3)) bounds.append(round(t1, 3))
mask = (times >= t0) & (times < t1) mask = (times >= t0) & (times < t1)
e = float(rms_n[mask].mean()) if mask.any() else 0.0 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 peak_t = float(times[mask][np.argmax(rms_n[mask])]) if mask.any() else t0
dur = round(t1 - t0, 2) 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({ segs.append({
"segment": i + 1, "start_s": round(t0, 2), "duration_s": dur, "frames": frames, "segment": i + 1, "group": 1 + sum(1 for gb in gbounds if t0 >= gb - 0.02),
"energy": label, "energy_val": round(e, 3), "peak_s": round(peak_t, 2), "start_s": round(t0, 2), "duration_s": dur, "frames": _snap8(round(dur * fps)),
"stage_hint": stage, "note": "", "energy": "high" if e > 0.66 else ("medium" if e > 0.33 else "low"),
"beats_in": [round(b, 2) for b in beats if t0 <= b < t1], "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) return segs, np.array(bounds), gbounds
class AudioWaveSegments: class AudioWaveSegments:
@@ -128,29 +150,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}),
# Fixed LTX clip length. Segments = chunks of this many frames (721@24fps # Hard split every this many frames (721@24fps ~= 30.04s = one LTX clip).
# ~= 30.04s). This is also the waveform grid. Set 0 to use segments_json. # Segments live inside a group and never cross a group line.
"subsegment_frames": ("INT", {"default": 721, "min": 0, "max": 100000}), "group_frames": ("INT", {"default": 721, "min": 1, "max": 100000}),
# 0 = whole clip. N = output ONLY subsegment N (crops image + audio + summary), # "" / "0" = whole clip. "N" = only segment N. "A-B" = segments A..B —
# so you can generate/skip one beat at a time. # crops image + audio + summary to that range (for generating/skipping beats).
"segment_select": ("INT", {"default": 0, "min": 0, "max": 999}), "segment_select": ("STRING", {"default": ""}),
"notes": ("STRING", {"default": "", "multiline": True}), "notes": ("STRING", {"default": "", "multiline": True}),
# Machine field: per-chunk notes/boundaries from the JS widget (hidden). # Machine field: the JS widget writes only USER split points here (hidden).
"segments_json": ("STRING", {"default": "[]"}), "segments_json": ("STRING", {"default": "[]"}),
}, },
} }
@classmethod @classmethod
def IS_CHANGED(cls, audio, fps, subsegment_frames, segment_select, notes, segments_json): def IS_CHANGED(cls, audio, fps, group_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}|{subsegment_frames}|{segment_select}|{notes}|{segments_json}|{mt}" return f"{audio}|{fps}|{group_frames}|{segment_select}|{notes}|{segments_json}|{mt}"
def run(self, audio, fps, subsegment_frames, segment_select, notes, segments_json): def run(self, audio, fps, group_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)
@@ -166,44 +188,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)
if subsegment_frames > 0: # fixed LTX-clip grid user_starts, seg_notes = [], {} # user split points from the JS widget
segs, bounds = _segments_fixed(rms_n, times, duration, fps, subsegment_frames, beats)
else: # manual boundaries from the JS widget
starts = []
try:
for seg in (json.loads(segments_json) if segments_json.strip() else []):
starts.append(float(seg.get("start_s", 0.0)))
except Exception as e:
print(f"[AudioWaveSegments] bad segments_json ({e})")
segs, bounds = _segments_from_boundaries(rms_n, times, duration, fps, starts, beats)
# per-chunk notes from segments_json (JS) then the notes-box (segN: syntax) on top.
try: try:
for i, seg in enumerate(json.loads(segments_json) if segments_json.strip() else []): for i, seg in enumerate(json.loads(segments_json) if segments_json.strip() else []):
if i < len(segs) and seg.get("note"): user_starts.append(float(seg.get("start_s", 0.0)))
segs[i]["note"] = str(seg["note"]) if seg.get("note"):
except Exception: seg_notes[round(float(seg.get("start_s", 0.0)), 2)] = str(seg["note"])
pass except Exception as e:
global_notes = _attach_notes(segs, notes) print(f"[AudioWaveSegments] bad segments_json ({e})")
window, render_segs = None, segs 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} audio_out = {"waveform": wav.unsqueeze(0), "sample_rate": sr}
sel = int(segment_select) if a >= 1 and segs: # crop to segment range A..B
if 1 <= sel <= len(segs): # crop to one subsegment s0 = segs[a - 1]["start_s"]
seg = segs[sel - 1] s1 = min(duration, segs[b - 1]["start_s"] + segs[b - 1]["duration_s"])
s0 = seg["start_s"] i0, i1 = int(s0 * sr), int(s1 * sr)
s1 = min(duration, s0 + seg["duration_s"]) if i1 > i0:
a, b = int(s0 * sr), int(s1 * sr) audio_out = {"waveform": wav[:, i0:i1].unsqueeze(0), "sample_rate": sr}
if b > a: window, sel = (s0, s1), (a, b)
audio_out = {"waveform": wav[:, a:b].unsqueeze(0), "sample_rate": sr} total = sum(s["frames"] for s in segs[a - 1:b])
window, render_segs = (s0, s1), [seg] g0, g1 = segs[a - 1]["group"], segs[b - 1]["group"]
summary = (f"SELECTED SUBSEGMENT {sel} of {len(segs)} — generate ONLY this beat.\n" gtxt = f"group {g0}" + ("" if g0 == g1 else f"{g1}")
+ _summary(duration, sr, bpm, beats, [seg], global_notes)) 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: else:
summary = _summary(duration, sr, bpm, beats, segs, global_notes) summary = _summary(duration, sr, bpm, beats, segs, global_notes)
image = _render(rms_n, times, duration, beats, bounds, render_segs, image = _render(rms_n, times, duration, beats, bounds, segs,
fps=fps, frames_marker=subsegment_frames, window=window) fps=fps, group_frames=group_frames, window=window, sel=sel)
return (image, summary, audio_out) return (image, summary, audio_out)
+82 -88
View File
@@ -1,6 +1,6 @@
// Audio Wave + Segments — waveform + playback, a fixed subsegment grid (721 frames @ fps), // Audio Wave + Segments — mirrored waveform + playback, a bold 721-frame group grid,
// per-chunk notes auto-filled into the `notes` box, playhead + click-to-seek. // optional fine user splits inside groups, per-segment notes auto-filled into the `notes`
// First cut: open the browser console for [audiowave] logs if something misbehaves. // box, playhead + click-to-seek. Open the console for [audiowave] logs on trouble.
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
@@ -12,8 +12,7 @@ function computePeaks(buf, n) {
const peaks = new Float32Array(n); const peaks = new Float32Array(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 s = i * block;
const s = i * block;
for (let j = 0; j < block && s + j < data.length; j++) { const v = Math.abs(data[s + j]); if (v > m) m = v; } for (let j = 0; j < block && s + j < data.length; j++) { const v = Math.abs(data[s + j]); if (v > m) m = v; }
peaks[i] = m; if (m > max) max = m; peaks[i] = m; if (m > max) max = m;
} }
@@ -21,39 +20,26 @@ function computePeaks(buf, n) {
return peaks; return peaks;
} }
// Keep a ComfyUI DOM widget at full node width (fixes the collapse-to-half-width bug on // Keep a ComfyUI DOM widget at full node width (fixes collapse-to-half on select).
// selection / re-layout). Portable helper; call once after addDOMWidget.
function keepDomWidgetFullWidth(node, container) { function keepDomWidgetFullWidth(node, container) {
const GRID_SEL = '[data-testid="node-widgets"], .lg-node-widgets'; const GRID_SEL = '[data-testid="node-widgets"], .lg-node-widgets';
const ROW_SEL = '[data-testid="node-widget"], .lg-node-widget'; const ROW_SEL = '[data-testid="node-widget"], .lg-node-widget';
const MAX_MARGIN = 40; const MAX_MARGIN = 40;
let enforcing = false, marginLogical = Infinity, gridObserver = null; let enforcing = false, marginLogical = Infinity, gridObserver = null;
function refFromDom() { function refFromDom() {
const grid = container.closest(GRID_SEL); const grid = container.closest(GRID_SEL); if (!grid) return 0;
if (!grid) return 0;
let w = 0; let w = 0;
for (const row of Array.from(grid.querySelectorAll(ROW_SEL))) { for (const row of Array.from(grid.querySelectorAll(ROW_SEL))) { if (row.contains(container)) continue; const c = row.lastElementChild; if (c && c.clientWidth > w) w = c.clientWidth; }
if (row.contains(container)) continue; if (!w && grid.clientWidth > 0) { const dot = grid.querySelector(`${ROW_SEL.split(",")[0]} > :first-child`); w = grid.clientWidth - (dot?.offsetWidth ?? 0); }
const c = row.lastElementChild;
if (c && c.clientWidth > w) w = c.clientWidth;
}
if (!w && grid.clientWidth > 0) {
const dot = grid.querySelector(`${ROW_SEL.split(",")[0]} > :first-child`);
w = grid.clientWidth - (dot?.offsetWidth ?? 0);
}
return w; return w;
} }
function refFromNodeSize(cw) { function refFromNodeSize(cw) {
const nodeW = node.size?.[0] ?? 0; const nodeW = node.size?.[0] ?? 0; if (nodeW <= 0) return 0;
if (nodeW <= 0) return 0;
if (cw > 0) { const m = nodeW - cw; if (m >= 0 && m < marginLogical) marginLogical = Math.min(m, MAX_MARGIN); } if (cw > 0) { const m = nodeW - cw; if (m >= 0 && m < marginLogical) marginLogical = Math.min(m, MAX_MARGIN); }
const margin = Number.isFinite(marginLogical) ? marginLogical : MAX_MARGIN / 2; return Math.round(nodeW - (Number.isFinite(marginLogical) ? marginLogical : MAX_MARGIN / 2));
return Math.round(nodeW - margin);
} }
function reference(cw) { function reference(cw) {
let w = refFromDom(); let w = refFromDom(); if (!w) w = refFromNodeSize(cw);
if (!w) w = refFromNodeSize(cw);
if (!w) for (const wd of node.widgets ?? []) { const el = wd.inputEl || wd.element; if (el && el !== container && el.offsetWidth > w) w = el.offsetWidth; } if (!w) for (const wd of node.widgets ?? []) { const el = wd.inputEl || wd.element; if (el && el !== container && el.offsetWidth > w) w = el.offsetWidth; }
if (!w && container.parentElement) w = container.parentElement.clientWidth; if (!w && container.parentElement) w = container.parentElement.clientWidth;
return w; return w;
@@ -61,19 +47,16 @@ function keepDomWidgetFullWidth(node, container) {
function enforce() { function enforce() {
if (enforcing) return; if (enforcing) return;
if (!gridObserver) { const grid = container.closest(GRID_SEL); if (grid) { gridObserver = new ResizeObserver(enforce); gridObserver.observe(grid); } } if (!gridObserver) { const grid = container.closest(GRID_SEL); if (grid) { gridObserver = new ResizeObserver(enforce); gridObserver.observe(grid); } }
const cw = container.clientWidth; const cw = container.clientWidth, ref = reference(cw);
const ref = reference(cw);
if (ref > 0 && Math.abs(cw - ref) > 2) { enforcing = true; container.style.width = ref + "px"; requestAnimationFrame(() => { enforcing = false; }); } if (ref > 0 && Math.abs(cw - ref) > 2) { enforcing = true; container.style.width = ref + "px"; requestAnimationFrame(() => { enforcing = false; }); }
} }
const ro = new ResizeObserver(enforce); const ro = new ResizeObserver(enforce); ro.observe(container);
ro.observe(container); const origOnResize = node.onResize; node.onResize = function (s) { origOnResize?.call(this, s); enforce(); };
const origOnResize = node.onResize;
node.onResize = function (size) { origOnResize?.call(this, size); enforce(); };
return () => { ro.disconnect(); gridObserver?.disconnect(); }; return () => { ro.disconnect(); gridObserver?.disconnect(); };
} }
function setupWave(node) { function setupWave(node) {
const st = { duration: 0, peaks: null, audio: new Audio(), playing: false }; const st = { duration: 0, peaks: null, splits: [], audio: new Audio(), playing: false };
node._wave = st; node._wave = st;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@@ -83,85 +66,92 @@ function setupWave(node) {
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"), upBtn = mk("upload"); const playBtn = mk("▶ play"), upBtn = mk("upload");
const readout = document.createElement("span"); readout.textContent = "0.00 / 0.00s"; const readout = document.createElement("span"); readout.textContent = "0.00 / 0.00s";
const hint = document.createElement("span"); hint.textContent = "click=seek · dblclick a chunk=note"; const hint = document.createElement("span"); hint.textContent = "click=seek · dblclick=add split · shift-click a split=remove";
bar.append(playBtn, upBtn, readout, hint); bar.append(playBtn, upBtn, readout, hint);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 640; canvas.height = 160; canvas.width = 900; canvas.height = 170;
canvas.style.cssText = "width:100%;height:160px;background:#141418;border-radius:4px;cursor:pointer;"; canvas.style.cssText = "width:100%;height:170px;background:#141418;border-radius:4px;cursor:pointer;";
wrap.append(bar, canvas); wrap.append(bar, canvas);
// Neutral type (NOT "preview" — that triggers aspect-ratio sizing that collapses width), node.addDOMWidget("wave", "wave", wrap, { serialize: false, getMinHeight: () => 225 });
// with an explicit height, then pin the width against the collapse-on-select bug.
node.addDOMWidget("wave", "wave", wrap, { serialize: false, getMinHeight: () => 205 });
const cleanupWidth = keepDomWidgetFullWidth(node, wrap); const cleanupWidth = keepDomWidgetFullWidth(node, wrap);
const origRemoved = node.onRemoved; const origRemoved = node.onRemoved;
node.onRemoved = function () { try { cleanupWidth(); } catch (e) { /* ignore */ } return origRemoved?.apply(this, arguments); }; node.onRemoved = function () { try { cleanupWidth(); } catch (e) { /* ignore */ } return origRemoved?.apply(this, arguments); };
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
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 fps = () => Math.max(1, Number(getW(node, "fps")?.value) || 24);
const subFrames = () => Math.max(0, Number(getW(node, "subsegment_frames")?.value) || 0); const groupFrames = () => Math.max(1, Number(getW(node, "group_frames")?.value) || 721); // guard: never 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);
function chunkStarts() { function groupBounds() {
const step = subFrames() / fps(); const step = groupFrames() / fps(); const arr = [];
const arr = []; if (!st.duration || step <= 0) return arr;
if (!st.duration || !step) return [0]; for (let t = step; t < st.duration - 1e-6; t += step) arr.push(t);
for (let t = 0; t < st.duration - 1e-6; t += step) arr.push(t); return arr;
return arr.length ? arr : [0]; }
function segStarts() { // union of 0 + user splits + group lines
const set = new Set([0]);
for (const s of st.splits) if (s > 0 && s < st.duration) set.add(Math.round(s * 100) / 100);
for (const g of groupBounds()) set.add(Math.round(g * 100) / 100);
return Array.from(set).sort((a, b) => a - b);
} }
function serialize() { // write ONLY user splits (small + stable)
const w = getW(node, "segments_json"); if (!w) return;
w.value = JSON.stringify(st.splits.map((s) => ({ start_s: Math.round(s * 100) / 100 })));
w.callback?.(w.value);
}
function parseNotes(txt) { function parseNotes(txt) {
const seg = {}, glob = []; const seg = {}, glob = [];
(txt || "").split("\n").forEach((l) => { (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); });
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 }; return { seg, glob };
} }
function syncNotesBox() { // one segN: line per segment, preserve notes
function syncNotesBox() { // one "segN:" line per chunk, preserve notes
const nw = getW(node, "notes"); if (!nw) return; const nw = getW(node, "notes"); if (!nw) return;
const { seg, glob } = parseNotes(nw.value); const { seg, glob } = parseNotes(nw.value);
const n = chunkStarts().length; const n = segStarts().length; const lines = [];
const lines = [];
for (let i = 1; i <= n; i++) lines.push(`seg${i}: ${seg[i] !== undefined ? seg[i] : ""}`); for (let i = 1; i <= n; i++) lines.push(`seg${i}: ${seg[i] !== undefined ? seg[i] : ""}`);
const val = lines.concat(glob).join("\n"); const val = lines.concat(glob).join("\n");
if (nw.value !== val) { nw.value = val; nw.callback?.(val); } if (nw.value !== val) { nw.value = val; nw.callback?.(val); }
} }
function setChunkNote(i, val) { function selRange() {
const nw = getW(node, "notes"); if (!nw) return; const v = (getW(node, "segment_select")?.value || "").trim();
const { seg, glob } = parseNotes(nw.value); const m = v.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (m) return [+m[1], +m[2]].sort((a, b) => a - b);
seg[i] = val; if (/^\d+$/.test(v) && v !== "0") return [+v, +v];
const n = chunkStarts().length; return null;
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() {
ctx.fillStyle = "#141418"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#141418"; ctx.fillRect(0, 0, canvas.width, canvas.height);
if (st.peaks) { const H = canvas.height, mid = H / 2;
ctx.fillStyle = "#3c8cdc"; const starts = segStarts();
const n = st.peaks.length, bw = canvas.width / n; const sel = selRange();
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); } if (sel) { // shade selected range
const a = starts[sel[0] - 1], b = sel[1] < starts.length ? starts[sel[1]] : st.duration;
if (a !== undefined) { ctx.fillStyle = "#26364f"; ctx.fillRect(t2x(a), 0, t2x(b) - t2x(a), H); }
} }
ctx.font = "10px monospace"; if (st.peaks) { // mirrored waveform around centre
ctx.strokeStyle = "#3c8cdc"; ctx.beginPath();
const n = st.peaks.length;
for (let i = 0; i < n; i++) { const x = (i / n) * canvas.width, h = st.peaks[i] * (H * 0.44); ctx.moveTo(x, mid - h); ctx.lineTo(x, mid + h); }
ctx.stroke();
}
ctx.strokeStyle = "#ececf2"; ctx.lineWidth = 2; // bold group grid
for (const g of groupBounds()) { const x = t2x(g); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
ctx.lineWidth = 1;
const { seg } = parseNotes(getW(node, "notes")?.value); const { seg } = parseNotes(getW(node, "notes")?.value);
const starts = chunkStarts(); ctx.font = "10px monospace";
starts.forEach((s, i) => { starts.forEach((s, i) => { // segment line + label top + note bottom
const x = t2x(s); const x = t2x(s);
ctx.strokeStyle = "#7ec8a0"; 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, H); ctx.stroke();
ctx.fillStyle = "#fff"; ctx.fillText(`S${i + 1} ${s.toFixed(1)}s`, x + 3, 11); ctx.fillStyle = "#fff"; ctx.fillText(`S${i + 1} ${s.toFixed(1)}s`, x + 3, 11);
if (seg[i + 1]) { ctx.fillStyle = "#ffd27a"; ctx.fillText(seg[i + 1].slice(0, 22), x + 3, 23); } if (seg[i + 1]) { ctx.fillStyle = "#ffd27a"; ctx.fillText(seg[i + 1].slice(0, 22), x + 3, H - 4); }
}); });
if (st.playing || st.audio.currentTime) { ctx.strokeStyle = "#ff5a3c"; const px = t2x(st.audio.currentTime || 0); // playhead
const x = t2x(st.audio.currentTime); ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
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`; readout.textContent = `${(st.audio.currentTime || 0).toFixed(2)} / ${st.duration.toFixed(2)}s`;
} }
@@ -177,18 +167,21 @@ function setupWave(node) {
} catch (e) { console.error("[audiowave] could not load/decode", name, e); } } catch (e) { console.error("[audiowave] could not load/decode", name, e); }
} }
canvas.addEventListener("mousedown", (e) => { // click = seek const tol = () => x2t(6);
const r = canvas.getBoundingClientRect(); canvas.addEventListener("mousedown", (e) => {
st.audio.currentTime = Math.max(0, Math.min(st.duration, x2t(((e.clientX - r.left) / r.width) * canvas.width)));
draw();
});
canvas.addEventListener("dblclick", (e) => { // dblclick a chunk = edit its note
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 starts = chunkStarts(); let i = 0; for (let k = 0; k < starts.length; k++) if (t >= starts[k]) i = k; if (e.shiftKey) { // shift-click = remove nearest user split
const { seg } = parseNotes(getW(node, "notes")?.value); let bi = -1, bd = 1e9; st.splits.forEach((s, i) => { const dd = Math.abs(s - t); if (dd < tol() && dd < bd) { bd = dd; bi = i; } });
const val = window.prompt(`Note for subsegment ${i + 1}:`, seg[i + 1] || ""); if (bi >= 0) { st.splits.splice(bi, 1); serialize(); syncNotesBox(); draw(); }
if (val !== null) { setChunkNote(i + 1, val); draw(); } return;
}
st.audio.currentTime = Math.max(0, Math.min(st.duration, t)); draw(); // click = seek
});
canvas.addEventListener("dblclick", (e) => { // add a user split
const r = canvas.getBoundingClientRect();
const t = x2t(((e.clientX - r.left) / r.width) * canvas.width);
if (t > 0.05 && t < st.duration - 0.05) { st.splits.push(t); serialize(); syncNotesBox(); draw(); }
}); });
const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); }; const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); };
@@ -213,11 +206,12 @@ function setupWave(node) {
inp.click(); inp.click();
}; };
// re-grid + re-fill notes when the audio, fps or subsegment_frames change
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; }; } }; 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; }; } };
hook("audio", () => loadFile(getW(node, "audio")?.value)); hook("audio", () => loadFile(getW(node, "audio")?.value));
hook("fps", () => { syncNotesBox(); draw(); }); hook("fps", () => { syncNotesBox(); draw(); });
hook("subsegment_frames", () => { syncNotesBox(); draw(); }); hook("group_frames", () => { syncNotesBox(); draw(); });
hook("segment_select", () => draw());
try { const sj = getW(node, "segments_json"); if (sj?.value) st.splits = (JSON.parse(sj.value) || []).map((s) => s.start_s).filter((s) => s > 0); } catch (e) { /* ignore */ }
const aw = getW(node, "audio"); if (aw?.value) loadFile(aw.value); const aw = getW(node, "audio"); if (aw?.value) loadFile(aw.value);
draw(); draw();
} }
+1 -1
View File
@@ -114,7 +114,7 @@
"audio.wav", "audio.wav",
24, 24,
721, 721,
0, "",
"", "",
"[]" "[]"
] ]
+2 -2
View File
@@ -13,8 +13,8 @@
"inputs": { "inputs": {
"audio": "audio.wav", "audio": "audio.wav",
"fps": 24, "fps": 24,
"subsegment_frames": 721, "group_frames": 721,
"segment_select": 0, "segment_select": "",
"notes": "", "notes": "",
"segments_json": "[]" "segments_json": "[]"
}, },