Major Issue Fix

Resolved issue preventing audio from playing in the background when sent from Silly Tavern. Resolved issue preventing API settings to not impact node such as volume and toggles.
This commit is contained in:
nullara
2026-04-21 17:23:33 -07:00
parent 5279b3e715
commit b760094a76
4 changed files with 225 additions and 82 deletions
+7
View File
@@ -0,0 +1,7 @@
.gitattributes
.gitignore
LICENSE
README.md
__init__.py
js/auto_audio_player.js
pyproject.toml
+26 -8
View File
@@ -1,4 +1,3 @@
import io
import os
import uuid
import wave
@@ -14,6 +13,18 @@ WEB_DIRECTORY = "./js"
TEMP_SUBDIR = "auto_audio_player"
LATEST_AUDIO_STATE = {
"revision": 0,
"filename": None,
"url": None,
"sample_rate": None,
"channels": None,
"duration": None,
"autoplay": True,
"default_volume": 1.0,
"loop": False,
}
def _get_temp_root() -> Path:
base = Path(tempfile.gettempdir()) / "ComfyUI" / TEMP_SUBDIR
@@ -36,28 +47,23 @@ def _to_numpy_audio(audio):
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]
pcm_interleaved = pcm.T
with wave.open(str(audio_path), "wb") as wav_file:
wav_file.setnchannels(pcm.shape[0])
@@ -78,6 +84,11 @@ async def serve_auto_audio_player_file(request):
return web.FileResponse(path, headers={"Cache-Control": "no-store"})
@PromptServer.instance.routes.get("/auto_audio_player/latest")
async def get_latest_auto_audio_player_state(request):
return web.json_response(LATEST_AUDIO_STATE, headers={"Cache-Control": "no-store"})
class AutoAudioPlayer:
@classmethod
def INPUT_TYPES(cls):
@@ -97,6 +108,8 @@ class AutoAudioPlayer:
OUTPUT_NODE = True
def process(self, audio, autoplay=True, default_volume=1.0, loop=False):
global LATEST_AUDIO_STATE
waveform, sample_rate = _to_numpy_audio(audio)
filename = f"auto_audio_{uuid.uuid4().hex}.wav"
out_path = _get_temp_root() / filename
@@ -115,6 +128,11 @@ class AutoAudioPlayer:
"loop": bool(loop),
}
LATEST_AUDIO_STATE = {
"revision": int(LATEST_AUDIO_STATE.get("revision", 0)) + 1,
**ui_payload,
}
return {"ui": {"audio": [ui_payload]}, "result": (audio,)}
@@ -126,4 +144,4 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"AutoAudioPlayer": "Auto Audio Player",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+190 -72
View File
@@ -1,6 +1,19 @@
import { app } from '../../scripts/app.js'
console.log('[AutoAudioPlayer] FILE LOADED polling build')
let autoAudioUnlocked = false
const autoAudioNodeInstances = new Map()
let latestSeenRevision = 0
window.addEventListener('unhandledrejection', (event) => {
console.warn('[AutoAudioPlayer] global unhandled rejection:', event.reason)
})
function logAAP(...args) {
console.log('[AutoAudioPlayer]', ...args)
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const mins = Math.floor(seconds / 60)
@@ -8,19 +21,6 @@ function formatTime(seconds) {
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'
@@ -53,10 +53,155 @@ function hideWidget(widget) {
}
}
async function unlockAudioPlayback() {
if (autoAudioUnlocked) return true
try {
const probe = document.createElement('audio')
probe.muted = true
probe.playsInline = true
const result = probe.play()
if (result && typeof result.then === 'function') {
await result.catch(() => {})
}
probe.pause()
} catch (_) {
// ignore
}
autoAudioUnlocked = true
logAAP('audio unlock attempted')
return true
}
async function safePlay(audioEl, statusEl, reason = 'unknown') {
if (!audioEl?.src) {
logAAP('safePlay skipped, no src', reason)
return false
}
try {
logAAP('safePlay attempting', reason, audioEl.src)
const result = audioEl.play()
if (result && typeof result.then === 'function') {
await result
}
logAAP('safePlay success', reason)
return true
} catch (err) {
console.warn('[AutoAudioPlayer] play() failed:', reason, err)
if (statusEl && !statusEl.textContent.includes('Autoplay blocked')) {
statusEl.textContent += ' • Autoplay blocked by browser until you click in the ComfyUI page once.'
}
return false
}
}
function applyPayloadToNode(node, payload, source = 'polling') {
const ui = node?.__autoAudioPlayer
if (!ui) return
if (!payload?.url) {
ui.status.textContent = 'Execution finished, but no audio preview payload was returned.'
logAAP('applyPayloadToNode no payload url', source, payload)
return
}
const cacheBustedUrl = `${payload.url}${payload.url.includes('?') ? '&' : '?'}t=${Date.now()}`
logAAP('setting audio src', source, cacheBustedUrl)
const preferredVolume = Number.isFinite(Number(payload.default_volume))
? Number(payload.default_volume)
: Number.isFinite(Number(findWidget(node, 'default_volume')?.value))
? Number(findWidget(node, 'default_volume')?.value)
: Number(ui.volume.value ?? 1)
const preferredLoop = payload.loop ?? findWidget(node, 'loop')?.value ?? ui.loopToggle.checked
const preferredAutoplay = payload.autoplay ?? findWidget(node, 'autoplay')?.value ?? ui.autoplayToggle.checked
setWidgetValue(findWidget(node, 'default_volume'), preferredVolume, node)
setWidgetValue(findWidget(node, 'loop'), !!preferredLoop, node)
setWidgetValue(findWidget(node, 'autoplay'), preferredAutoplay !== false, node)
ui.audio.pause()
ui.audio.currentTime = 0
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) {
Promise.resolve()
.then(async () => {
logAAP('attempting autoplay', source)
await safePlay(ui.audio, ui.status, `${source}-autoplay`)
ui.syncButtonLabel()
})
.catch((err) => {
console.warn('[AutoAudioPlayer] autoplay promise chain failed:', source, err)
ui.syncButtonLabel()
})
}
node.setDirtyCanvas?.(true, true)
}
async function pollLatestAudioState() {
try {
const response = await fetch('/auto_audio_player/latest', { cache: 'no-store' })
if (!response.ok) return
const data = await response.json()
const revision = Number(data?.revision ?? 0)
if (!revision || revision <= latestSeenRevision) return
latestSeenRevision = revision
logAAP('poll hit new revision', revision, data)
for (const node of autoAudioNodeInstances.values()) {
applyPayloadToNode(node, data, 'polling')
}
} catch (err) {
console.warn('[AutoAudioPlayer] polling failed:', err)
}
}
if (!window.__autoAudioPlayerUnlockBound) {
window.__autoAudioPlayerUnlockBound = true
const unlockOnce = async () => {
await unlockAudioPlayback()
window.removeEventListener('pointerdown', unlockOnce, true)
window.removeEventListener('keydown', unlockOnce, true)
}
window.addEventListener('pointerdown', unlockOnce, true)
window.addEventListener('keydown', unlockOnce, true)
}
if (!window.__autoAudioPlayerPollingBound) {
window.__autoAudioPlayerPollingBound = true
setInterval(pollLatestAudioState, 1000)
}
app.registerExtension({
name: 'custom.auto_audio_player',
async beforeRegisterNodeDef(nodeType, nodeData) {
console.log('[AutoAudioPlayer] beforeRegisterNodeDef seen', nodeData?.name)
const nodeName = nodeData?.name || nodeData?.display_name || nodeType?.comfyClass || nodeType?.type
if (nodeName !== 'AutoAudioPlayer') return
@@ -66,6 +211,7 @@ app.registerExtension({
if (this.__autoAudioPlayerInitialized) return result
this.__autoAudioPlayerInitialized = true
logAAP('nodeCreated', this.id)
const autoplayWidget = findWidget(this, 'autoplay')
const volumeWidget = findWidget(this, 'default_volume')
@@ -106,6 +252,7 @@ app.registerExtension({
const audio = document.createElement('audio')
audio.preload = 'auto'
audio.playsInline = true
audio.style.display = 'none'
const playRow = document.createElement('div')
@@ -216,7 +363,6 @@ app.registerExtension({
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)
@@ -246,9 +392,10 @@ app.registerExtension({
if (!audio.src) return
try {
if (audio.paused) {
await audio.play()
await safePlay(audio, status, 'manual-button')
} else {
audio.pause()
logAAP('manual pause')
}
} catch (err) {
console.warn('[AutoAudioPlayer] play/pause error:', err)
@@ -267,32 +414,46 @@ app.registerExtension({
const value = Number(volume.value)
audio.volume = value
setWidgetValue(volumeWidget, value, this)
logAAP('volume changed', value)
}
loopToggle.onchange = () => {
audio.loop = loopToggle.checked
setWidgetValue(loopWidget, loopToggle.checked, this)
logAAP('loop changed', loopToggle.checked)
}
autoplayToggle.onchange = () => {
setWidgetValue(autoplayWidget, autoplayToggle.checked, this)
logAAP('autoplay changed', autoplayToggle.checked)
}
audio.addEventListener('play', syncButtonLabel)
audio.addEventListener('pause', syncButtonLabel)
audio.addEventListener('play', () => {
logAAP('audio event: play')
syncButtonLabel()
})
audio.addEventListener('pause', () => {
logAAP('audio event: pause')
syncButtonLabel()
})
audio.addEventListener('timeupdate', () => {
syncProgress()
syncTimeLabel()
})
audio.addEventListener('loadedmetadata', () => {
logAAP('audio event: loadedmetadata', audio.duration)
syncProgress()
syncTimeLabel()
})
audio.addEventListener('ended', () => {
logAAP('audio event: ended')
syncButtonLabel()
syncProgress()
syncTimeLabel()
})
audio.addEventListener('error', () => {
logAAP('audio event: error', audio.error)
})
this.__autoAudioPlayer = {
status,
@@ -306,60 +467,17 @@ app.registerExtension({
syncTimeLabel,
}
autoAudioNodeInstances.set(String(this.id), this)
logAAP('registered node instance', this.id)
const onRemoved = this.onRemoved
this.onRemoved = function () {
autoAudioNodeInstances.delete(String(this.id))
logAAP('removed node instance', this.id)
return onRemoved?.apply(this, arguments)
}
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)
}
}
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "auto-audio-player"
description = ""
version = "1.0.0"
description = "An auto audio player node for ComyUI. Offers a looping toggle, autoplay toggle, scrubbing, volume control, pause and play. Once audio is pushed into the node it will automatically play."
version = "1.1.0"
license = "MIT"
# classifiers = [
# # For OS-independent nodes (works on all operating systems)