// 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"; function getW(node, name) { return node.widgets?.find((w) => w.name === name); } function computePeaks(buf, n) { const data = buf.getChannelData(0); const block = Math.max(1, Math.floor(data.length / n)); const peaks = new Float32Array(n); let max = 1e-6; for (let i = 0; i < n; i++) { 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; } for (let i = 0; i < n; i++) peaks[i] /= max; 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. 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; 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); } return w; } function refFromNodeSize(cw) { 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); } function reference(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; } 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); 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(); }; return () => { ro.disconnect(); gridObserver?.disconnect(); }; } function setupWave(node) { const st = { duration: 0, peaks: null, audio: new Audio(), playing: false }; node._wave = st; const wrap = document.createElement("div"); wrap.style.cssText = "display:flex;flex-direction:column;gap:4px;width:100%;"; 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"), 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 = 160; canvas.style.cssText = "width:100%;height:160px;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 }); 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 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); 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() { 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); } } ctx.font = "10px monospace"; const { seg } = parseNotes(getW(node, "notes")?.value); const starts = chunkStarts(); starts.forEach((s, i) => { const x = t2x(s); 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 || 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 || 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; syncNotesBox(); draw(); } 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 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(); } }); const loop = () => { if (!st.playing) return; draw(); requestAnimationFrame(loop); }; playBtn.onclick = () => { if (st.audio.paused) { st.audio.play().catch((e) => console.error("[audiowave] play", e)); st.playing = true; playBtn.textContent = "⏸ pause"; loop(); } else { st.audio.pause(); st.playing = false; playBtn.textContent = "▶ play"; } }; st.audio.onended = () => { st.playing = false; playBtn.textContent = "▶ play"; draw(); }; upBtn.onclick = () => { const inp = document.createElement("input"); inp.type = "file"; inp.accept = "audio/*"; inp.onchange = async () => { const f = inp.files[0]; if (!f) return; const fd = new FormData(); fd.append("image", f, f.name); fd.append("type", "input"); 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; } await loadFile(name); } catch (e) { console.error("[audiowave] upload failed (drop the file in ComfyUI/input instead)", e); } }; 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(); }); const aw = getW(node, "audio"); if (aw?.value) loadFile(aw.value); draw(); } app.registerExtension({ name: "promptcalib.audiowave", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData?.name !== "AudioWaveSegments") return; const orig = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const r = orig?.apply(this, arguments); try { setupWave(this); } catch (e) { console.error("[audiowave] setup failed", e); } return r; }; }, });