diff --git a/__init__.py b/__init__.py index 4c2b733..9f19996 100644 --- a/__init__.py +++ b/__init__.py @@ -224,6 +224,7 @@ NODE_CONFIG = { "ModelMemoryUsageFactorOverride": {"class": ModelMemoryUsageFactorOverride, "name": "Model Memory Usage Factor Override"}, "WanChunkFeedForward": {"class": WanChunkFeedForward, "name": "Wan ChunkFeedForward"}, "SamplerSelfRefineVideo": {"class": SamplerSelfRefineVideo, "name": "Sampler SelfRefineVideo"}, + "PlaySoundKJ": {"class": PlaySoundKJ, "name": "Play Sound KJ"}, #instance diffusion "CreateInstanceDiffusionTracking": {"class": CreateInstanceDiffusionTracking}, diff --git a/nodes/nodes.py b/nodes/nodes.py index 83ce370..4f1273e 100644 --- a/nodes/nodes.py +++ b/nodes/nodes.py @@ -3310,3 +3310,43 @@ class PreviewLatentNoiseMask(io.ComfyNode): noise_mask = noise_mask[0, 0] return io.NodeOutput(noise_mask) + + +class PlaySoundKJ(io.ComfyNode): + """Plays audio in the browser when execution reaches this node.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="PlaySoundKJ", + category="KJNodes/audio", + description="Plays the input audio in the browser. Modes: 'always' plays on every execution, 'on_empty_queue' plays only when the queue finishes, 'on_change' plays only when the audio content changes. Duration limits playback length (0 = full audio).", + inputs=[ + io.Audio.Input("audio"), + io.Combo.Input("mode", options=["always", "on_empty_queue", "on_change"], default="always"), + io.Float.Input("volume", default=0.5, min=0.0, max=1.0, step=0.01), + io.Float.Input("duration", default=5.0, min=0.0, max=300.0, step=0.1, tooltip="Duration in seconds to play. 0 = play full audio."), + io.AnyType.Input("any_input", optional=True), + ], + outputs=[ + io.AnyType.Output("any_output", display_name="any_output"), + ], + is_output_node=True, + ) + + @classmethod + def fingerprint_inputs(cls, **kwargs): + if kwargs.get("mode") == "on_change": + return False + return float("NaN") + + @classmethod + def execute(cls, audio, mode="always", volume=0.5, duration=5.0, any_input=None) -> io.NodeOutput: + preview = ui.PreviewAudio(audio, cls=cls) + ui_dict = preview.as_dict() + waveform = audio["waveform"] + ui_dict["audio_hash"] = [hash(waveform.sum().item())] + return io.NodeOutput( + any_input, + ui=ui_dict, + ) diff --git a/web/js/play_sound.js b/web/js/play_sound.js new file mode 100644 index 0000000..cda30ca --- /dev/null +++ b/web/js/play_sound.js @@ -0,0 +1,94 @@ +const { app } = window.comfyAPI.app; +const { api } = window.comfyAPI.api; + +app.registerExtension({ + name: "KJNodes.PlaySound", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "PlaySoundKJ") return; + + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (output) { + onExecuted?.call(this, output); + + const audios = output?.audio; + if (!audios?.length) return; + + const modeWidget = this.widgets?.find(w => w.name === "mode"); + const volumeWidget = this.widgets?.find(w => w.name === "volume"); + const durationWidget = this.widgets?.find(w => w.name === "duration"); + const mode = modeWidget?.value ?? "always"; + const volume = volumeWidget?.value ?? 0.5; + const duration = durationWidget?.value ?? 0; + + // on_change: skip if audio content hasn't changed + if (mode === "on_change") { + const audioHash = output?.audio_hash?.[0]; + if (audioHash != null && this._kjLastAudioHash === audioHash) return; + this._kjLastAudioHash = audioHash; + } + + // Clean up previous state + if (this._kjStatusListener) { + api.removeEventListener("status", this._kjStatusListener); + this._kjStatusListener = null; + } + clearTimeout(this._kjQueueDebounce); + this._kjPendingAudio = null; + + if (this._kjPlayingAudio) { + this._kjPlayingAudio.pause(); + this._kjPlayingAudio = null; + } + if (this._kjPlayTimer != null) { + clearTimeout(this._kjPlayTimer); + this._kjPlayTimer = null; + } + + const startPlayback = () => { + const { filename, subfolder, type } = audios[0]; + const params = new URLSearchParams({ + filename: filename ?? "", + subfolder: subfolder ?? "", + type: type ?? "temp", + }); + const url = api.apiURL(`/view?${params.toString()}`); + const audio = new Audio(url); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.play().catch(() => {}); + this._kjPlayingAudio = audio; + if (duration > 0) { + this._kjPlayTimer = setTimeout(() => { + audio.pause(); + this._kjPlayingAudio = null; + this._kjPlayTimer = null; + }, duration * 1000); + } + }; + + if (mode === "on_empty_queue") { + this._kjPendingAudio = startPlayback; + this._kjStatusListener = ({ detail }) => { + const remaining = detail?.exec_info?.queue_remaining ?? 0; + if (remaining === 0) { + // Debounce: confirm queue is truly empty + // (status can briefly show 0 between dispatches) + clearTimeout(this._kjQueueDebounce); + this._kjQueueDebounce = setTimeout(() => { + if (this._kjPendingAudio) { + this._kjPendingAudio(); + this._kjPendingAudio = null; + } + api.removeEventListener("status", this._kjStatusListener); + this._kjStatusListener = null; + }, 1000); + } else { + clearTimeout(this._kjQueueDebounce); + } + }; + api.addEventListener("status", this._kjStatusListener); + } else { + startPlayback(); + } + }; + }, +});