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:
+82
-88
@@ -1,6 +1,6 @@
|
||||
// Audio Wave + Segments — waveform + playback, a fixed subsegment grid (721 frames @ fps),
|
||||
// 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.
|
||||
// Audio Wave + Segments — mirrored waveform + playback, a bold 721-frame group grid,
|
||||
// optional fine user splits inside groups, per-segment notes auto-filled into the `notes`
|
||||
// box, playhead + click-to-seek. Open the console for [audiowave] logs on trouble.
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
@@ -12,8 +12,7 @@ function computePeaks(buf, n) {
|
||||
const peaks = new Float32Array(n);
|
||||
let max = 1e-6;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let m = 0;
|
||||
const s = i * block;
|
||||
let m = 0; 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; }
|
||||
peaks[i] = m; if (m > max) max = m;
|
||||
}
|
||||
@@ -21,39 +20,26 @@ function computePeaks(buf, n) {
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Keep a ComfyUI DOM widget at full node width (fixes the collapse-to-half-width bug on
|
||||
// selection / re-layout). Portable helper; call once after addDOMWidget.
|
||||
// Keep a ComfyUI DOM widget at full node width (fixes collapse-to-half on select).
|
||||
function keepDomWidgetFullWidth(node, container) {
|
||||
const GRID_SEL = '[data-testid="node-widgets"], .lg-node-widgets';
|
||||
const ROW_SEL = '[data-testid="node-widget"], .lg-node-widget';
|
||||
const MAX_MARGIN = 40;
|
||||
let enforcing = false, marginLogical = Infinity, gridObserver = null;
|
||||
|
||||
function refFromDom() {
|
||||
const grid = container.closest(GRID_SEL);
|
||||
if (!grid) return 0;
|
||||
const grid = container.closest(GRID_SEL); if (!grid) return 0;
|
||||
let w = 0;
|
||||
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 (!w && grid.clientWidth > 0) {
|
||||
const dot = grid.querySelector(`${ROW_SEL.split(",")[0]} > :first-child`);
|
||||
w = grid.clientWidth - (dot?.offsetWidth ?? 0);
|
||||
}
|
||||
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 (!w && grid.clientWidth > 0) { const dot = grid.querySelector(`${ROW_SEL.split(",")[0]} > :first-child`); w = grid.clientWidth - (dot?.offsetWidth ?? 0); }
|
||||
return w;
|
||||
}
|
||||
function refFromNodeSize(cw) {
|
||||
const nodeW = node.size?.[0] ?? 0;
|
||||
if (nodeW <= 0) return 0;
|
||||
const nodeW = node.size?.[0] ?? 0; if (nodeW <= 0) return 0;
|
||||
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 - margin);
|
||||
return Math.round(nodeW - (Number.isFinite(marginLogical) ? marginLogical : MAX_MARGIN / 2));
|
||||
}
|
||||
function reference(cw) {
|
||||
let w = refFromDom();
|
||||
if (!w) w = refFromNodeSize(cw);
|
||||
let w = refFromDom(); 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 && container.parentElement) w = container.parentElement.clientWidth;
|
||||
return w;
|
||||
@@ -61,19 +47,16 @@ function keepDomWidgetFullWidth(node, container) {
|
||||
function enforce() {
|
||||
if (enforcing) return;
|
||||
if (!gridObserver) { const grid = container.closest(GRID_SEL); if (grid) { gridObserver = new ResizeObserver(enforce); gridObserver.observe(grid); } }
|
||||
const cw = container.clientWidth;
|
||||
const ref = reference(cw);
|
||||
const cw = container.clientWidth, ref = reference(cw);
|
||||
if (ref > 0 && Math.abs(cw - ref) > 2) { enforcing = true; container.style.width = ref + "px"; requestAnimationFrame(() => { enforcing = false; }); }
|
||||
}
|
||||
const ro = new ResizeObserver(enforce);
|
||||
ro.observe(container);
|
||||
const origOnResize = node.onResize;
|
||||
node.onResize = function (size) { origOnResize?.call(this, size); enforce(); };
|
||||
const ro = new ResizeObserver(enforce); ro.observe(container);
|
||||
const origOnResize = node.onResize; node.onResize = function (s) { origOnResize?.call(this, s); enforce(); };
|
||||
return () => { ro.disconnect(); gridObserver?.disconnect(); };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 playBtn = mk("▶ play"), upBtn = mk("upload");
|
||||
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);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640; canvas.height = 160;
|
||||
canvas.style.cssText = "width:100%;height:160px;background:#141418;border-radius:4px;cursor:pointer;";
|
||||
canvas.width = 900; canvas.height = 170;
|
||||
canvas.style.cssText = "width:100%;height:170px;background:#141418;border-radius:4px;cursor:pointer;";
|
||||
wrap.append(bar, canvas);
|
||||
// Neutral type (NOT "preview" — that triggers aspect-ratio sizing that collapses width),
|
||||
// with an explicit height, then pin the width against the collapse-on-select bug.
|
||||
node.addDOMWidget("wave", "wave", wrap, { serialize: false, getMinHeight: () => 205 });
|
||||
node.addDOMWidget("wave", "wave", wrap, { serialize: false, getMinHeight: () => 225 });
|
||||
const cleanupWidth = keepDomWidgetFullWidth(node, wrap);
|
||||
const origRemoved = node.onRemoved;
|
||||
node.onRemoved = function () { try { cleanupWidth(); } catch (e) { /* ignore */ } return origRemoved?.apply(this, arguments); };
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const sjw = getW(node, "segments_json"); // machine field — hide it
|
||||
if (sjw) { sjw.hidden = true; sjw.computeSize = () => [0, -4]; }
|
||||
const sjw = getW(node, "segments_json"); 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 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 x2t = (x) => (st.duration ? (x / canvas.width) * st.duration : 0);
|
||||
|
||||
function chunkStarts() {
|
||||
const step = subFrames() / fps();
|
||||
const arr = [];
|
||||
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 groupBounds() {
|
||||
const step = groupFrames() / fps(); const arr = [];
|
||||
if (!st.duration || step <= 0) return arr;
|
||||
for (let t = step; t < st.duration - 1e-6; t += step) arr.push(t);
|
||||
return arr;
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
(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
|
||||
function syncNotesBox() { // one segN: line per segment, preserve notes
|
||||
const nw = getW(node, "notes"); if (!nw) return;
|
||||
const { seg, glob } = parseNotes(nw.value);
|
||||
const n = chunkStarts().length;
|
||||
const lines = [];
|
||||
const n = segStarts().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 selRange() {
|
||||
const v = (getW(node, "segment_select")?.value || "").trim();
|
||||
const m = v.match(/^(\d+)\s*[-:]\s*(\d+)$/); if (m) return [+m[1], +m[2]].sort((a, b) => a - b);
|
||||
if (/^\d+$/.test(v) && v !== "0") return [+v, +v];
|
||||
return null;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.fillStyle = "#141418"; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
if (st.peaks) {
|
||||
ctx.fillStyle = "#3c8cdc";
|
||||
const n = st.peaks.length, bw = canvas.width / n;
|
||||
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); }
|
||||
const H = canvas.height, mid = H / 2;
|
||||
const starts = segStarts();
|
||||
const sel = selRange();
|
||||
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 starts = chunkStarts();
|
||||
starts.forEach((s, i) => {
|
||||
ctx.font = "10px monospace";
|
||||
starts.forEach((s, i) => { // segment line + label top + note bottom
|
||||
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);
|
||||
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) {
|
||||
const x = t2x(st.audio.currentTime);
|
||||
ctx.strokeStyle = "#ff5a3c"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = "#ff5a3c"; const px = t2x(st.audio.currentTime || 0); // playhead
|
||||
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||||
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); }
|
||||
}
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => { // click = seek
|
||||
const r = canvas.getBoundingClientRect();
|
||||
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 tol = () => x2t(6);
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
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;
|
||||
const { seg } = parseNotes(getW(node, "notes")?.value);
|
||||
const val = window.prompt(`Note for subsegment ${i + 1}:`, seg[i + 1] || "");
|
||||
if (val !== null) { setChunkNote(i + 1, val); draw(); }
|
||||
if (e.shiftKey) { // shift-click = remove nearest user split
|
||||
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; } });
|
||||
if (bi >= 0) { st.splits.splice(bi, 1); serialize(); syncNotesBox(); 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); };
|
||||
@@ -213,11 +206,12 @@ function setupWave(node) {
|
||||
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; }; } };
|
||||
hook("audio", () => loadFile(getW(node, "audio")?.value));
|
||||
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);
|
||||
draw();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user