diff --git a/.tracking b/.tracking new file mode 100644 index 0000000..81ee753 --- /dev/null +++ b/.tracking @@ -0,0 +1,7 @@ +.gitattributes +.gitignore +LICENSE +README.md +__init__.py +js/auto_audio_player.js +pyproject.toml \ No newline at end of file diff --git a/__init__.py b/__init__.py index 7bb3feb..1417708 100644 --- a/__init__.py +++ b/__init__.py @@ -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"] \ No newline at end of file diff --git a/js/auto_audio_player.js b/js/auto_audio_player.js index ab682a5..7413776 100644 --- a/js/auto_audio_player.js +++ b/js/auto_audio_player.js @@ -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) - } } -}) +}) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f5e7220..e2a6748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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)