v1 Auto Audio Player Node

This commit is contained in:
nullara
2026-04-21 14:43:06 -07:00
parent 3287cca89c
commit c63e3748c1
5 changed files with 587 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.DS_Store
Thumbs.db
+21
View File
@@ -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.
+66
View File
@@ -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
+129
View File
@@ -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"]
@@ -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)
}
}
})