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:
2026-07-04 23:12:44 +02:00
co-authored by Claude Opus 4.8
parent e63f6e6058
commit 75b20f9656
6 changed files with 198 additions and 107 deletions
+78 -60
View File
@@ -1,5 +1,5 @@
// Audio Wave + Segments — waveform display + playback + click-to-segment.
// Writes the segment boundaries/notes into the node's hidden `segments_json` widget.
// 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.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
@@ -13,20 +13,16 @@ function computePeaks(buf, n) {
let max = 1e-6;
for (let i = 0; i < n; i++) {
let m = 0;
const start = i * block;
for (let j = 0; j < block && start + j < data.length; j++) {
const v = Math.abs(data[start + j]);
if (v > m) m = v;
}
peaks[i] = m;
if (m > max) max = m;
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;
}
for (let i = 0; i < n; i++) peaks[i] /= max;
return peaks;
}
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;
const wrap = document.createElement("div");
@@ -34,29 +30,60 @@ function setupWave(node) {
const bar = document.createElement("div");
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 playBtn = mk("▶ play"), clearBtn = mk("clear"), upBtn = mk("upload");
const hint = document.createElement("span");
hint.textContent = "click=add split · drag=move · dblclick=note · shift/right-click=delete";
bar.append(playBtn, clearBtn, upBtn, hint);
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";
bar.append(playBtn, upBtn, readout, hint);
const canvas = document.createElement("canvas");
canvas.width = 640; canvas.height = 150;
canvas.style.cssText = "width:100%;height:150px;background:#141418;border-radius:4px;cursor:crosshair;";
canvas.width = 640; canvas.height = 160;
canvas.style.cssText = "width:100%;height:160px;background:#141418;border-radius:4px;cursor:pointer;";
wrap.append(bar, canvas);
node.addDOMWidget("wave", "wave", wrap, { serialize: false });
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");
const sjw = getW(node, "segments_json"); // machine field — hide it
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 x2t = (x) => (st.duration ? (x / canvas.width) * st.duration : 0);
const starts = () => [0, ...st.boundaries.slice().sort((a, b) => a - b)];
function serialize() {
const segs = starts().map((s, i) => ({ start_s: Math.round(s * 100) / 100, note: st.notes[i] || "" }));
const w = getW(node, "segments_json");
if (w) { w.value = JSON.stringify(segs); w.callback?.(w.value); }
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 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() {
@@ -64,57 +91,49 @@ function setupWave(node) {
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 - 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";
starts().forEach((s, i) => {
const { seg } = parseNotes(getW(node, "notes")?.value);
const starts = chunkStarts();
starts.forEach((s, i) => {
const x = t2x(s);
ctx.strokeStyle = "#eee"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
ctx.fillStyle = "#fff"; ctx.fillText("S" + (i + 1), x + 3, 11);
if (st.notes[i]) { ctx.fillStyle = "#ffd27a"; ctx.fillText(st.notes[i].slice(0, 24), x + 3, 23); }
ctx.strokeStyle = "#7ec8a0"; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); 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 (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) {
if (!name) return;
if (!name || name.startsWith("(")) return;
const url = `/view?filename=${encodeURIComponent(name)}&type=input&subfolder=`;
try {
const ab = await (await fetch(url)).arrayBuffer();
const ac = new (window.AudioContext || window.webkitAudioContext)();
const buf = await ac.decodeAudioData(ab.slice(0));
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); }
}
const tol = () => x2t(6);
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) => {
canvas.addEventListener("mousedown", (e) => { // click = seek
const r = canvas.getBoundingClientRect();
const t = 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)));
st.audio.currentTime = Math.max(0, Math.min(st.duration, x2t(((e.clientX - r.left) / r.width) * canvas.width)));
draw();
});
window.addEventListener("mouseup", () => { if (dragIdx >= 0) { serialize(); dragIdx = -1; draw(); } });
canvas.addEventListener("dblclick", (e) => {
canvas.addEventListener("dblclick", (e) => { // dblclick a chunk = edit its note
const r = canvas.getBoundingClientRect();
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 val = window.prompt(`Note for segment ${si + 1}:`, st.notes[si] || "");
if (val !== null) { st.notes[si] = val; serialize(); draw(); }
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(); }
});
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); };
playBtn.onclick = () => {
@@ -122,7 +141,6 @@ function setupWave(node) {
else { st.audio.pause(); st.playing = false; playBtn.textContent = "▶ play"; }
};
st.audio.onended = () => { st.playing = false; playBtn.textContent = "▶ play"; draw(); };
clearBtn.onclick = () => { st.boundaries = []; st.notes = {}; serialize(); draw(); };
upBtn.onclick = () => {
const inp = document.createElement("input"); inp.type = "file"; inp.accept = "audio/*";
@@ -132,19 +150,19 @@ function setupWave(node) {
try {
const res = await api.fetchApi("/upload/image", { method: "POST", body: fd });
const j = await res.json(); const name = j.name || f.name;
const w = getW(node, "audio");
if (w) { if (!w.options.values.includes(name)) w.options.values.push(name); w.value = name; }
const w = getW(node, "audio"); if (w) { if (!w.options.values.includes(name)) w.options.values.push(name); w.value = name; }
await loadFile(name);
} catch (e) { console.error("[audiowave] upload failed (drop the file in ComfyUI/input instead)", e); }
};
inp.click();
};
// react to the audio combo changing + initial load
const aw = getW(node, "audio");
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); }
// restore boundaries/notes from a reloaded workflow
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 */ }
// 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(); });
const aw = getW(node, "audio"); if (aw?.value) loadFile(aw.value);
draw();
}