From c63e3748c1e8148da39004d4486ef03d98430ea9 Mon Sep 17 00:00:00 2001 From: nullara Date: Tue, 21 Apr 2026 14:43:06 -0700 Subject: [PATCH] v1 Auto Audio Player Node --- ComfyUI-AutoAudioPlayer/.gitignore | 6 + ComfyUI-AutoAudioPlayer/LICENSE | 21 + ComfyUI-AutoAudioPlayer/README.md | 66 ++++ ComfyUI-AutoAudioPlayer/__init__.py | 129 +++++++ .../js/auto_audio_player.js | 365 ++++++++++++++++++ 5 files changed, 587 insertions(+) create mode 100644 ComfyUI-AutoAudioPlayer/.gitignore create mode 100644 ComfyUI-AutoAudioPlayer/LICENSE create mode 100644 ComfyUI-AutoAudioPlayer/README.md create mode 100644 ComfyUI-AutoAudioPlayer/__init__.py create mode 100644 ComfyUI-AutoAudioPlayer/js/auto_audio_player.js diff --git a/ComfyUI-AutoAudioPlayer/.gitignore b/ComfyUI-AutoAudioPlayer/.gitignore new file mode 100644 index 0000000..90b4807 --- /dev/null +++ b/ComfyUI-AutoAudioPlayer/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.DS_Store +Thumbs.db diff --git a/ComfyUI-AutoAudioPlayer/LICENSE b/ComfyUI-AutoAudioPlayer/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/ComfyUI-AutoAudioPlayer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ComfyUI-AutoAudioPlayer/README.md b/ComfyUI-AutoAudioPlayer/README.md new file mode 100644 index 0000000..8651556 --- /dev/null +++ b/ComfyUI-AutoAudioPlayer/README.md @@ -0,0 +1,66 @@ +# ComfyUI Auto Audio Player + +A lightweight ComfyUI custom node that automatically plays audio when a new `AUDIO` input reaches the node. + +## Features + +- Auto-play when fresh audio reaches the node +- Play / Pause control +- Seek bar for scrubbing through the clip +- Volume control +- Loop toggle +- Autoplay toggle +- Passes the original `AUDIO` through unchanged for downstream nodes + +## Node + +- **Name:** `Auto Audio Player` +- **Category:** `audio/utils` + +## Inputs + +- `audio` (`AUDIO`) +- `autoplay` (`BOOLEAN`, default `true`) +- `default_volume` (`FLOAT`, default `1.0`) +- `loop` (`BOOLEAN`, default `false`) + +## Output + +- `audio` (`AUDIO`) passthrough + +## Install + +1. Clone or download this repository. +2. Place the folder in your ComfyUI `custom_nodes` directory. +3. Restart ComfyUI. +4. Add **Auto Audio Player** from `audio/utils`. +5. Connect any `AUDIO` output into it. + +Example: + +```text +Audio Source -> Auto Audio Player -> Any downstream AUDIO node +``` + +## Notes + +- The node writes a temporary WAV file so the frontend can load and play it. +- Some browsers block autoplay until the page has received user interaction. In that case, the node still loads the clip and the Play button works normally. +- The custom UI hides the duplicate built-in control widgets and keeps playback controls in a single panel. +- Frontend code uses ComfyUI's extension registration pattern with `WEB_DIRECTORY = "./js"` and a frontend script loaded from `js/auto_audio_player.js`. + +## Files + +```text +ComfyUI-AutoAudioPlayer/ +├── __init__.py +├── LICENSE +├── README.md +├── .gitignore +└── js/ + └── auto_audio_player.js +``` + +## License + +MIT diff --git a/ComfyUI-AutoAudioPlayer/__init__.py b/ComfyUI-AutoAudioPlayer/__init__.py new file mode 100644 index 0000000..7bb3feb --- /dev/null +++ b/ComfyUI-AutoAudioPlayer/__init__.py @@ -0,0 +1,129 @@ +import io +import os +import uuid +import wave +import tempfile +from pathlib import Path + +import numpy as np +import torch +from aiohttp import web +from server import PromptServer + +WEB_DIRECTORY = "./js" + +TEMP_SUBDIR = "auto_audio_player" + + +def _get_temp_root() -> Path: + base = Path(tempfile.gettempdir()) / "ComfyUI" / TEMP_SUBDIR + base.mkdir(parents=True, exist_ok=True) + return base + + +def _to_numpy_audio(audio): + if not isinstance(audio, dict): + raise TypeError("Expected ComfyUI AUDIO input as a dict with waveform and sample_rate.") + + waveform = audio.get("waveform") + sample_rate = int(audio.get("sample_rate", 44100)) + + if waveform is None: + raise ValueError("Audio input is missing 'waveform'.") + + if isinstance(waveform, torch.Tensor): + data = waveform.detach().cpu().float().numpy() + else: + data = np.asarray(waveform, dtype=np.float32) + + # Common ComfyUI audio shapes include: + # [batch, channels, samples], [channels, samples], [samples] + if data.ndim == 3: + data = data[0] + if data.ndim == 1: + data = data[np.newaxis, :] + elif data.ndim == 2: + # If someone hands in [samples, channels], rotate it. + if data.shape[0] > data.shape[1] and data.shape[1] <= 8: + data = data.T + else: + raise ValueError(f"Unsupported waveform shape: {data.shape}") + + # Final shape: [channels, samples] + data = np.clip(data, -1.0, 1.0) + return data, sample_rate + + +def _write_wav(audio_path: Path, waveform: np.ndarray, sample_rate: int): + # Convert float32 [-1, 1] to 16-bit PCM. + pcm = (waveform * 32767.0).astype(np.int16) + pcm_interleaved = pcm.T # [samples, channels] + + with wave.open(str(audio_path), "wb") as wav_file: + wav_file.setnchannels(pcm.shape[0]) + wav_file.setsampwidth(2) + wav_file.setframerate(sample_rate) + wav_file.writeframes(pcm_interleaved.tobytes()) + + +@PromptServer.instance.routes.get("/auto_audio_player/{filename}") +async def serve_auto_audio_player_file(request): + filename = request.match_info["filename"] + safe_name = os.path.basename(filename) + path = _get_temp_root() / safe_name + + if not path.exists() or not path.is_file(): + return web.Response(status=404, text="Audio file not found.") + + return web.FileResponse(path, headers={"Cache-Control": "no-store"}) + + +class AutoAudioPlayer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "autoplay": ("BOOLEAN", {"default": True}), + "default_volume": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "loop": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio",) + FUNCTION = "process" + CATEGORY = "audio/utils" + OUTPUT_NODE = True + + def process(self, audio, autoplay=True, default_volume=1.0, loop=False): + waveform, sample_rate = _to_numpy_audio(audio) + filename = f"auto_audio_{uuid.uuid4().hex}.wav" + out_path = _get_temp_root() / filename + _write_wav(out_path, waveform, sample_rate) + + duration = float(waveform.shape[1]) / float(sample_rate) if sample_rate > 0 else 0.0 + + ui_payload = { + "filename": filename, + "url": f"/auto_audio_player/{filename}", + "sample_rate": sample_rate, + "channels": int(waveform.shape[0]), + "duration": duration, + "autoplay": bool(autoplay), + "default_volume": float(default_volume), + "loop": bool(loop), + } + + return {"ui": {"audio": [ui_payload]}, "result": (audio,)} + + +NODE_CLASS_MAPPINGS = { + "AutoAudioPlayer": AutoAudioPlayer, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AutoAudioPlayer": "Auto Audio Player", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/ComfyUI-AutoAudioPlayer/js/auto_audio_player.js b/ComfyUI-AutoAudioPlayer/js/auto_audio_player.js new file mode 100644 index 0000000..ab682a5 --- /dev/null +++ b/ComfyUI-AutoAudioPlayer/js/auto_audio_player.js @@ -0,0 +1,365 @@ + +import { app } from '../../scripts/app.js' + +function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00' + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${String(secs).padStart(2, '0')}` +} + +function getPayload(message) { + const direct = Array.isArray(message?.audio) ? message.audio[0] : message?.audio + if (direct?.url) return direct + + const nestedUi = Array.isArray(message?.ui?.audio) ? message.ui.audio[0] : message?.ui?.audio + if (nestedUi?.url) return nestedUi + + const nestedResult = Array.isArray(message?.result?.ui?.audio) ? message.result.ui.audio[0] : message?.result?.ui?.audio + if (nestedResult?.url) return nestedResult + + return null +} + +function styleRange(input) { + input.style.width = '100%' + input.style.margin = '0' + input.style.minWidth = '0' + input.style.accentColor = '#b866ff' +} + +function findWidget(node, name) { + return node.widgets?.find((w) => w?.name === name) || null +} + +function setWidgetValue(widget, value, node) { + if (!widget) return + widget.value = value + if (typeof widget.callback === 'function') widget.callback(value) + if (typeof node?.setDirtyCanvas === 'function') node.setDirtyCanvas(true, true) +} + +function hideWidget(widget) { + if (!widget || widget.__autoAudioHidden) return + widget.__autoAudioHidden = true + widget.hidden = true + widget.type = 'converted-widget' + widget.computeSize = () => [0, 0] + widget.draw = () => {} + widget.mouse = () => false + widget.serializeValue = () => widget.value + if (widget.element) { + widget.element.style.display = 'none' + } +} + +app.registerExtension({ + name: 'custom.auto_audio_player', + + async beforeRegisterNodeDef(nodeType, nodeData) { + const nodeName = nodeData?.name || nodeData?.display_name || nodeType?.comfyClass || nodeType?.type + if (nodeName !== 'AutoAudioPlayer') return + + const onNodeCreated = nodeType.prototype.onNodeCreated + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments) + + if (this.__autoAudioPlayerInitialized) return result + this.__autoAudioPlayerInitialized = true + + const autoplayWidget = findWidget(this, 'autoplay') + const volumeWidget = findWidget(this, 'default_volume') + const loopWidget = findWidget(this, 'loop') + hideWidget(autoplayWidget) + hideWidget(volumeWidget) + hideWidget(loopWidget) + + requestAnimationFrame(() => { + hideWidget(autoplayWidget) + hideWidget(volumeWidget) + hideWidget(loopWidget) + this.setDirtyCanvas?.(true, true) + }) + + const container = document.createElement('div') + container.style.display = 'flex' + container.style.flexDirection = 'column' + container.style.gap = '10px' + container.style.padding = '12px' + container.style.width = '100%' + container.style.boxSizing = 'border-box' + container.style.background = 'rgba(255,255,255,0.04)' + container.style.border = '1px solid rgba(255,255,255,0.08)' + container.style.borderRadius = '12px' + container.style.color = '#e8e8ea' + container.style.fontSize = '12px' + container.style.fontFamily = 'inherit' + container.style.overflow = 'hidden' + + const status = document.createElement('div') + status.textContent = 'Waiting for audio...' + status.style.fontSize = '12px' + status.style.lineHeight = '1.35' + status.style.fontWeight = '600' + status.style.opacity = '0.95' + status.style.wordBreak = 'break-word' + + const audio = document.createElement('audio') + audio.preload = 'auto' + audio.style.display = 'none' + + const playRow = document.createElement('div') + playRow.style.display = 'grid' + playRow.style.gridTemplateColumns = '64px minmax(0,1fr) auto' + playRow.style.alignItems = 'center' + playRow.style.columnGap = '10px' + playRow.style.rowGap = '6px' + + const playPauseButton = document.createElement('button') + playPauseButton.textContent = 'Play' + playPauseButton.style.height = '34px' + playPauseButton.style.minWidth = '64px' + playPauseButton.style.padding = '0 12px' + playPauseButton.style.border = '1px solid rgba(255,255,255,0.10)' + playPauseButton.style.borderRadius = '10px' + playPauseButton.style.background = 'rgba(255,255,255,0.10)' + playPauseButton.style.color = '#f2f2f3' + playPauseButton.style.cursor = 'pointer' + playPauseButton.style.fontWeight = '700' + playPauseButton.style.fontSize = '12px' + playPauseButton.style.lineHeight = '1' + + const progress = document.createElement('input') + progress.type = 'range' + progress.min = '0' + progress.max = '1000' + progress.step = '1' + progress.value = '0' + styleRange(progress) + + const timeLabel = document.createElement('div') + timeLabel.textContent = '0:00 / 0:00' + timeLabel.style.fontSize = '12px' + timeLabel.style.opacity = '0.92' + timeLabel.style.minWidth = '78px' + timeLabel.style.textAlign = 'right' + timeLabel.style.fontVariantNumeric = 'tabular-nums' + timeLabel.style.whiteSpace = 'nowrap' + timeLabel.style.fontWeight = '600' + + playRow.append(playPauseButton, progress, timeLabel) + + const controlsGrid = document.createElement('div') + controlsGrid.style.display = 'grid' + controlsGrid.style.gridTemplateColumns = '64px minmax(0,1fr) auto' + controlsGrid.style.alignItems = 'center' + controlsGrid.style.columnGap = '10px' + controlsGrid.style.rowGap = '10px' + + const volumeLabel = document.createElement('div') + volumeLabel.textContent = 'Vol' + volumeLabel.style.fontWeight = '700' + volumeLabel.style.opacity = '0.95' + + const volume = document.createElement('input') + volume.type = 'range' + volume.min = '0' + volume.max = '1' + volume.step = '0.01' + volume.value = String(volumeWidget?.value ?? 1) + styleRange(volume) + + const loopWrap = document.createElement('label') + loopWrap.style.display = 'inline-flex' + loopWrap.style.alignItems = 'center' + loopWrap.style.justifySelf = 'end' + loopWrap.style.gap = '6px' + loopWrap.style.whiteSpace = 'nowrap' + loopWrap.style.cursor = 'pointer' + loopWrap.style.userSelect = 'none' + loopWrap.style.fontWeight = '700' + + const loopToggle = document.createElement('input') + loopToggle.type = 'checkbox' + loopToggle.style.margin = '0' + loopToggle.style.accentColor = '#b866ff' + loopToggle.checked = !!loopWidget?.value + + const loopText = document.createElement('span') + loopText.textContent = 'Loop' + + loopWrap.append(loopToggle, loopText) + + const autoplayLabel = document.createElement('div') + autoplayLabel.textContent = 'Autoplay' + autoplayLabel.style.fontWeight = '700' + autoplayLabel.style.opacity = '0.95' + + const autoplayToggleWrap = document.createElement('label') + autoplayToggleWrap.style.display = 'inline-flex' + autoplayToggleWrap.style.alignItems = 'center' + autoplayToggleWrap.style.justifySelf = 'start' + autoplayToggleWrap.style.gap = '6px' + autoplayToggleWrap.style.cursor = 'pointer' + autoplayToggleWrap.style.userSelect = 'none' + autoplayToggleWrap.style.fontWeight = '700' + + const autoplayToggle = document.createElement('input') + autoplayToggle.type = 'checkbox' + autoplayToggle.style.margin = '0' + autoplayToggle.style.accentColor = '#b866ff' + autoplayToggle.checked = autoplayWidget?.value !== false + + autoplayToggleWrap.append(autoplayToggle) + + const spacer = document.createElement('div') + spacer.style.minWidth = '1px' + + controlsGrid.append(volumeLabel, volume, loopWrap, autoplayLabel, autoplayToggleWrap, spacer) + + container.append(status, playRow, controlsGrid, audio) + + this.addDOMWidget('auto_audio_player', 'auto_audio_player', container) + this.size = [Math.max(this.size?.[0] || 320, 360), Math.max(this.size?.[1] || 220, 200)] + this.setDirtyCanvas(true, true) + + const syncButtonLabel = () => { + playPauseButton.textContent = audio.paused ? 'Play' : 'Pause' + } + + const syncTimeLabel = () => { + const current = audio.currentTime || 0 + const total = Number.isFinite(audio.duration) ? audio.duration : 0 + timeLabel.textContent = `${formatTime(current)} / ${formatTime(total)}` + } + + const syncProgress = () => { + const total = Number.isFinite(audio.duration) ? audio.duration : 0 + if (total <= 0) { + progress.value = '0' + return + } + progress.value = String(Math.round((audio.currentTime / total) * 1000)) + } + + playPauseButton.onclick = async () => { + if (!audio.src) return + try { + if (audio.paused) { + await audio.play() + } else { + audio.pause() + } + } catch (err) { + console.warn('[AutoAudioPlayer] play/pause error:', err) + } + syncButtonLabel() + } + + progress.oninput = () => { + const total = Number.isFinite(audio.duration) ? audio.duration : 0 + if (total <= 0) return + audio.currentTime = (Number(progress.value) / 1000) * total + syncTimeLabel() + } + + volume.oninput = () => { + const value = Number(volume.value) + audio.volume = value + setWidgetValue(volumeWidget, value, this) + } + + loopToggle.onchange = () => { + audio.loop = loopToggle.checked + setWidgetValue(loopWidget, loopToggle.checked, this) + } + + autoplayToggle.onchange = () => { + setWidgetValue(autoplayWidget, autoplayToggle.checked, this) + } + + audio.addEventListener('play', syncButtonLabel) + audio.addEventListener('pause', syncButtonLabel) + audio.addEventListener('timeupdate', () => { + syncProgress() + syncTimeLabel() + }) + audio.addEventListener('loadedmetadata', () => { + syncProgress() + syncTimeLabel() + }) + audio.addEventListener('ended', () => { + syncButtonLabel() + syncProgress() + syncTimeLabel() + }) + + this.__autoAudioPlayer = { + status, + audio, + progress, + volume, + loopToggle, + autoplayToggle, + syncButtonLabel, + syncProgress, + syncTimeLabel, + } + + return result + } + + const onExecuted = nodeType.prototype.onExecuted + nodeType.prototype.onExecuted = function (message) { + onExecuted?.apply(this, arguments) + + const ui = this.__autoAudioPlayer + if (!ui) return + + const payload = getPayload(message) + if (!payload?.url) { + ui.status.textContent = 'Execution finished, but no audio preview payload was returned.' + console.warn('[AutoAudioPlayer] No usable payload found', message) + return + } + + const cacheBustedUrl = `${payload.url}${payload.url.includes('?') ? '&' : '?'}t=${Date.now()}` + const preferredVolume = Number.isFinite(Number(findWidget(this, 'default_volume')?.value)) + ? Number(findWidget(this, 'default_volume')?.value) + : Number.isFinite(Number(ui.volume.value)) + ? Number(ui.volume.value) + : Number(payload.default_volume ?? 1) + + const preferredLoop = findWidget(this, 'loop')?.value ?? payload.loop + const preferredAutoplay = findWidget(this, 'autoplay')?.value ?? payload.autoplay + + ui.audio.pause() + ui.audio.src = cacheBustedUrl + ui.audio.load() + + ui.volume.value = String(preferredVolume) + ui.audio.volume = preferredVolume + + ui.loopToggle.checked = !!preferredLoop + ui.audio.loop = ui.loopToggle.checked + + ui.autoplayToggle.checked = preferredAutoplay !== false + + const durationText = Number.isFinite(payload.duration) ? formatTime(payload.duration) : '0:00' + ui.status.textContent = `Audio ready • ${payload.sample_rate ?? '?'} Hz • ${payload.channels ?? '?'} ch • ${durationText}` + ui.syncButtonLabel() + ui.syncProgress() + ui.syncTimeLabel() + + if (preferredAutoplay) { + ui.audio.play().catch((err) => { + console.warn('[AutoAudioPlayer] Autoplay blocked or failed:', err) + ui.status.textContent += ' • Autoplay blocked by browser until you interact with the page.' + ui.syncButtonLabel() + }) + } + + this.setDirtyCanvas(true, true) + } + } +})