From dbbeca041c2a24fe0aff3ef1959201c5af0cafce Mon Sep 17 00:00:00 2001 From: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:59:50 -0500 Subject: [PATCH] fix: instant float preview latency --- pyproject.toml | 2 +- web/js/instant_float_preview.js | 334 ++++++++++++++++++++++---------- 2 files changed, 233 insertions(+), 103 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 961f166..3f89538 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_ryanonyheinside" description = "Custom nodes introducing everything-reactivity and timelines to ComfyUI" -version = "2.2.2" +version = "2.2.3" license = {file = "LICENSE"} dependencies = ["pygame", "opencv-python>=4.5.0", "scipy", "torchaudio", "pillow", "pymunk>=6.8.0,<7.0.0", "matplotlib", "openunmix", "mido", "scikit-image"] diff --git a/web/js/instant_float_preview.js b/web/js/instant_float_preview.js index 47adc04..b3231d8 100644 --- a/web/js/instant_float_preview.js +++ b/web/js/instant_float_preview.js @@ -4,6 +4,7 @@ const MIN_GRAPH_HEIGHT = 500; const AUDIO_HEIGHT = 44; const LOOP_BAR_H = 18; const HANDLE_W = 6; +const BRIDGE_FADE_SEC = 0.005; // 5ms crossfade — short enough to be imperceptible function buildAudioUrl(file) { const params = new URLSearchParams({ @@ -23,8 +24,9 @@ app.registerExtension({ const onSerialize = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (data) { onSerialize?.apply(this, arguments); - if (this._ifpLoop) { - data.ifpLoop = { ...this._ifpLoop }; + const loop = this._ifpLoop || this._ifpPendingLoop; + if (loop) { + data.ifpLoop = { ...loop }; } }; @@ -94,37 +96,28 @@ app.registerExtension({ canvas.style.cssText = "width:100%;flex:1;display:block;cursor:default;min-height:" + MIN_GRAPH_HEIGHT + "px;"; container.appendChild(canvas); - // Double-buffered audio: two elements, only the active one is visible. - // This allows seamless audio swaps: new audio loads in the background - // element while the old one keeps playing. const audioWrapper = document.createElement("div"); audioWrapper.style.cssText = "width:100%;display:none;flex-shrink:0;"; container.appendChild(audioWrapper); - const audioA = document.createElement("audio"); - audioA.controls = true; - audioA.preload = "auto"; - audioA.style.cssText = "width:100%;display:block;"; - audioWrapper.appendChild(audioA); - - const audioB = document.createElement("audio"); - audioB.controls = true; - audioB.preload = "auto"; - audioB.style.cssText = "width:100%;display:none;"; - audioWrapper.appendChild(audioB); + // Single audio element for native controls and normal playback. + // Gapless swaps are handled via an AudioBufferSourceNode bridge. + const audioEl = document.createElement("audio"); + audioEl.controls = true; + audioEl.preload = "auto"; + audioEl.style.cssText = "width:100%;display:block;"; + audioWrapper.appendChild(audioEl); this._ifpContainer = container; this._ifpCanvas = canvas; this._ifpAudioWrapper = audioWrapper; - this._ifpAudioA = audioA; - this._ifpAudioB = audioB; - this._ifpActiveAudio = audioA; - this._ifpInactiveAudio = audioB; + this._ifpAudioEl = audioEl; this._ifpData = null; this._ifpRafId = null; this._ifpAudioShown = false; this._ifpAudioDuration = 0; this._ifpSized = false; + this._ifpHasAudio = false; // Loop state this._ifpLoop = { enabled: false, start: 0, end: 1 }; @@ -134,10 +127,18 @@ app.registerExtension({ this._ifpPendingLoop = null; } this._ifpDrag = null; + + // Swap / bridge state this._ifpSwapping = false; - this._ifpRealTime = 0; - this._ifpRealPlaying = false; this._ifpSwapAbort = null; + this._ifpBridgeSource = null; + this._ifpBridgeStartCtxTime = 0; + this._ifpBridgeOffset = 0; + + // Web Audio API state (lazy-init on first audio load) + this._ifpAudioCtx = null; + this._ifpElementGain = null; + this._ifpBridgeGain = null; const self = this; const widget = this.addDOMWidget("instant_float_preview", "custom", container, { @@ -162,35 +163,51 @@ app.registerExtension({ canvas.addEventListener("mouseleave", (e) => this._ifpOnMouseUp(e)); canvas.addEventListener("dblclick", (e) => this._ifpOnDblClick(e)); - // Audio event listeners on both elements; only respond when the - // element is the active one and we're not mid-swap. - const setupAudioEvents = (el) => { - el.addEventListener("timeupdate", () => { - if (el !== this._ifpActiveAudio || this._ifpSwapping) return; - this._ifpRealTime = el.currentTime; - this._ifpCheckLoop(); - if (!this._ifpRafId) this._ifpDraw(); - }); - el.addEventListener("play", () => { - if (el !== this._ifpActiveAudio) return; - if (!this._ifpSwapping) this._ifpRealPlaying = true; - this._ifpStartRaf(); - }); - el.addEventListener("pause", () => { - if (el !== this._ifpActiveAudio || this._ifpSwapping) return; - this._ifpRealPlaying = false; - this._ifpStopRaf(); - this._ifpDraw(); - }); - el.addEventListener("ended", () => { - if (el !== this._ifpActiveAudio || this._ifpSwapping) return; - this._ifpRealPlaying = false; - this._ifpStopRaf(); - this._ifpDraw(); - }); - }; - setupAudioEvents(audioA); - setupAudioEvents(audioB); + // Audio event listeners on the single element + audioEl.addEventListener("timeupdate", () => { + if (this._ifpSwapping) return; + this._ifpCheckLoop(); + if (!this._ifpRafId) this._ifpDraw(); + }); + audioEl.addEventListener("play", () => { + // Resume AudioContext if suspended (browser autoplay policy) + if (this._ifpAudioCtx?.state === "suspended") { + this._ifpAudioCtx.resume(); + } + if (!this._ifpSwapping) this._ifpStartRaf(); + }); + audioEl.addEventListener("pause", () => { + if (this._ifpSwapping) return; + this._ifpStopRaf(); + this._ifpDraw(); + }); + audioEl.addEventListener("ended", () => { + if (this._ifpSwapping) return; + this._ifpStopRaf(); + this._ifpDraw(); + }); + }; + + // --- Web Audio API setup (called once, lazily) --- + // Creates the audio graph: + //