diff --git a/audio_loader/audio_loader.py b/audio_loader/audio_loader.py index 41c86f4..584f62f 100644 --- a/audio_loader/audio_loader.py +++ b/audio_loader/audio_loader.py @@ -1,5 +1,8 @@ import os import json +import hashlib +import tempfile +import asyncio import folder_paths audio_extensions = ["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus"] @@ -22,6 +25,20 @@ class AudioLoaderNode: }, "optional": { "normalize": ("BOOLEAN", {"default": False}), + "pitch_shift_semitones": ("FLOAT", { + "default": 0.0, + "min": -24.0, + "max": 24.0, + "step": 0.5, + "tooltip": ( + "Shifts the pitch of the extra 'audio_pitched' output by this " + "many semitones (positive = higher, negative = lower) while " + "keeping duration/tempo unchanged. Useful for nudging low " + "female (contralto) or high male voices into a range that a " + "voice-gender classifier (e.g. LTX Sound-to-Video) reads " + "correctly. The main 'audio' output is never altered." + ), + }), }, "hidden": { # trim_json is written by the JS widget and passed here. @@ -31,8 +48,12 @@ class AudioLoaderNode: } } - RETURN_TYPES = ("AUDIO", "INT", "FLOAT", "STRING") - RETURN_NAMES = ("audio", "sample_rate", "duration_seconds", "metadata") + # audio_pitched is appended at the END of the tuple (not inserted + # between existing outputs) so saved workflows that already wired the + # sample_rate/duration_seconds/metadata sockets keep pointing at the + # same output indices after this update. + RETURN_TYPES = ("AUDIO", "INT", "FLOAT", "STRING", "AUDIO") + RETURN_NAMES = ("audio", "sample_rate", "duration_seconds", "metadata", "audio_pitched") FUNCTION = "load_audio" CATEGORY = "audio" OUTPUT_NODE = False @@ -63,8 +84,55 @@ class AudioLoaderNode: except Exception as e: last_error = e raise RuntimeError(f"Could not load audio. Last error: {last_error}") + def _pitch_shift(self, waveform, sample_rate, n_steps): + """ + Returns a copy of `waveform` shifted by `n_steps` semitones, at the + same length (duration/tempo unchanged). Uses torchaudio's + phase-vocoder based PitchShift transform (resample -> time-stretch + -> resample), which is what keeps tempo constant while pitch moves. + """ + if abs(n_steps) < 1e-6: + return waveform.clone() + + # Guard against clips too short for the transform's internal STFT + # (default n_fft=512); avoids a cryptic crash on tiny trims. + min_samples = 2048 + if waveform.shape[-1] < min_samples: + print( + f"[AudioLoader] Clip too short ({waveform.shape[-1]} samples) to " + f"pitch-shift reliably — returning an unshifted copy for audio_pitched." + ) + return waveform.clone() + + try: + import torch + import torchaudio + except Exception as e: + raise RuntimeError( + f"torchaudio is required for pitch shifting but could not be imported: {e}" + ) + + shifter = torchaudio.transforms.PitchShift( + sample_rate=sample_rate, + n_steps=n_steps, + ) + shifted = shifter(waveform) + + # The internal resample/time-stretch chain can round to a slightly + # different sample count — force it back to the source length so + # audio_pitched always matches audio's duration exactly. + target_len = waveform.shape[-1] + cur_len = shifted.shape[-1] + if cur_len > target_len: + shifted = shifted[..., :target_len] + elif cur_len < target_len: + shifted = torch.nn.functional.pad(shifted, (0, target_len - cur_len)) + + return shifted + def load_audio(self, audio, normalize=False, - trim_json='{"s":0,"e":0}', unique_id="0"): + trim_json='{"s":0,"e":0}', unique_id="0", + pitch_shift_semitones=0.0): audio_path = folder_paths.get_annotated_filepath(audio) if not os.path.exists(audio_path): raise FileNotFoundError(f"Audio file not found: {audio_path}") @@ -98,11 +166,19 @@ class AudioLoaderNode: dur = waveform.shape[-1] / sample_rate filename = os.path.basename(audio_path) trim_info = f" | Trim: {t_start:.3f}s – {t_end:.3f}s" if do_trim else "" + + # Pitch-shifted twin of the (already trimmed/normalized) waveform. + # Computed from the same source as `audio`, so both outputs stay in + # sync with the same trim/normalize settings — only pitch differs. + pitch_shift_semitones = float(pitch_shift_semitones or 0.0) + pitched_waveform = self._pitch_shift(waveform, sample_rate, pitch_shift_semitones) + pitch_info = f" | Pitch: {pitch_shift_semitones:+.2f}st" if pitch_shift_semitones != 0.0 else "" + metadata = ( f"File: {filename} | Sample Rate: {sample_rate}Hz | " f"Channels: {waveform.shape[0]} | Samples: {waveform.shape[-1]} | " f"Duration: {dur:.3f}s | Size: {os.path.getsize(audio_path)/1024:.1f}KB" - f"{trim_info}" + f"{trim_info}{pitch_info}" ) return ( @@ -110,22 +186,25 @@ class AudioLoaderNode: sample_rate, dur, metadata, + {"waveform": pitched_waveform.unsqueeze(0), "sample_rate": sample_rate}, ) @classmethod def IS_CHANGED(cls, audio, normalize=False, - trim_json='{"s":0,"e":0}', unique_id="0"): + trim_json='{"s":0,"e":0}', unique_id="0", + pitch_shift_semitones=0.0): try: p = folder_paths.get_annotated_filepath(audio) if os.path.exists(p): - return f"{os.path.getmtime(p)}|{normalize}|{trim_json}" + return f"{os.path.getmtime(p)}|{normalize}|{trim_json}|{pitch_shift_semitones}" except Exception: pass - return f"{audio}|{normalize}|{trim_json}" + return f"{audio}|{normalize}|{trim_json}|{pitch_shift_semitones}" @classmethod def VALIDATE_INPUTS(cls, audio, normalize=False, - trim_json='{"s":0,"e":0}', unique_id="0"): + trim_json='{"s":0,"e":0}', unique_id="0", + pitch_shift_semitones=0.0): if not folder_paths.exists_annotated_filepath(audio): return f"Audio file does not exist: {audio}" return True @@ -134,6 +213,43 @@ class AudioLoaderNode: from server import PromptServer from aiohttp import web + +def _hash_file(path, chunk_size=1 << 20): + """MD5 checksum of a file's contents, read in chunks to bound memory.""" + h = hashlib.md5() + with open(path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def _name_family(input_dir, filename): + """ + Yields (candidate_name, full_path, exists) for filename, filename_1, + filename_2, ... — the same numbering scheme upload_audio already used + to dodge collisions — stopping right after the first name that doesn't + exist yet (that's the next free slot). + """ + base, ext = os.path.splitext(filename) + counter = 0 + while True: + candidate = filename if counter == 0 else f"{base}_{counter}{ext}" + path = os.path.join(input_dir, candidate) + exists = os.path.exists(path) + yield candidate, path, exists + if not exists: + return + counter += 1 + + +# Guards the "compare against existing files, then finalize" step of an +# upload so two near-simultaneous uploads of identical content can't both +# decide there's no match and race to create two separate numbered copies. +_upload_lock = asyncio.Lock() + @PromptServer.instance.routes.post("/axces2000/audio_trim") async def set_audio_trim(request): """JS posts trim JSON here; we echo it back so the prompt can include it.""" @@ -159,18 +275,63 @@ async def upload_audio(request): ext = os.path.splitext(filename)[1].lower().lstrip(".") if ext not in audio_extensions: return web.Response(status=400, text=f"Unsupported format: {ext}") + input_dir = folder_paths.get_input_directory() - save_path = os.path.join(input_dir, filename) - base, extension = os.path.splitext(filename) - counter = 1 - while os.path.exists(save_path): - filename = f"{base}_{counter}{extension}" - save_path = os.path.join(input_dir, filename) - counter += 1 - with open(save_path, "wb") as f: - while chunk := await field.read_chunk(8192): - f.write(chunk) - return web.json_response({"name": filename, "subfolder": "", "type": "input"}) + _, ext_with_dot = os.path.splitext(filename) + + # Stream the upload straight to a hidden temp file in the same folder + # (so the eventual rename below is atomic, same filesystem) while + # hashing it as the bytes arrive — no extra read pass just to checksum. + tmp_fd, tmp_path = tempfile.mkstemp( + prefix=".axces2000_upload_", suffix=ext_with_dot, dir=input_dir + ) + try: + hasher = hashlib.md5() + with os.fdopen(tmp_fd, "wb") as f: + while chunk := await field.read_chunk(8192): + f.write(chunk) + hasher.update(chunk) + upload_hash = hasher.hexdigest() + upload_size = os.path.getsize(tmp_path) + + async with _upload_lock: + # Walk voice.wav, voice_1.wav, voice_2.wav, ... — same chain + # this endpoint always used to dodge name collisions. If any + # existing file in that chain is byte-identical to what was + # just uploaded, reuse its name instead of writing a new + # numbered duplicate of the same content. + for candidate, path, exists in _name_family(input_dir, filename): + if not exists: + os.replace(tmp_path, path) + return web.json_response({ + "name": candidate, "subfolder": "", "type": "input", + "deduped": False, + }) + try: + same_content = ( + os.path.getsize(path) == upload_size + and _hash_file(path) == upload_hash + ) + except OSError: + # File vanished/unreadable mid-check — treat as no + # match and keep scanning the rest of the chain. + continue + if same_content: + os.remove(tmp_path) + return web.json_response({ + "name": candidate, "subfolder": "", "type": "input", + "deduped": True, + }) + + # Unreachable in practice — _name_family always terminates at a + # free slot — but fail loudly rather than silently drop the upload. + raise RuntimeError("Could not find a destination for the uploaded file") + finally: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass NODE_CLASS_MAPPINGS = {"AudioLoader": AudioLoaderNode} diff --git a/audio_loader/js/audio_loader.js b/audio_loader/js/audio_loader.js index 09dfbf4..31617cf 100644 --- a/audio_loader/js/audio_loader.js +++ b/audio_loader/js/audio_loader.js @@ -1,5 +1,17 @@ /** - * AudioLoader Widget for ComfyUI v2.6 + * AudioLoader Widget for ComfyUI v2.9 + * + * v2.9 — pitch_shift_semitones (an auto-generated FLOAT widget) could reach + * the backend as `null` in some ComfyUI frontend builds, which crashes + * ComfyUI's own prompt validator (float(None)) before the node ever runs. + * Fixed by giving that widget a serializeValue() that always emits a real + * finite number. See the comment at its creation site below for details. + * + * v2.8 — The server's /upload/audio route now dedups by content checksum: + * re-uploading a file that's byte-identical to one already sitting under + * the same name (or same-name_N) reuses that existing file instead of + * writing a new numbered duplicate. Purely server-side; the only JS change + * is logging data.deduped to the console for visibility. * * trim_json is declared as a hidden input in Python's INPUT_TYPES. * ComfyUI passes hidden inputs automatically in the prompt — but only if @@ -8,6 +20,17 @@ * We use the cleanest available hook: override the node's getExtraInfo() * which ComfyUI calls when building the prompt, injecting trim_json there. * Fallback: also set it via a beforeQueued hook on the audio combo widget. + * + * v2.7 — Added `pitch_shift_semitones`, a plain optional FLOAT input on the + * Python side. ComfyUI auto-generates its widget from INPUT_TYPES just like + * `normalize`, so it needs no special handling here. The one thing that + * *does* need care: adding a new auto-widget shifts every widget's position + * in node.widgets / widgets_values. The old trim-restore code below read + * widgets_values by a hardcoded index (vals[3]), which would silently break + * as soon as any new widget was added anywhere before it. It now reads the + * trim_json widget's own `.value` instead — LiteGraph already restores each + * widget's value from widgets_values (by position) before onConfigure runs, + * so this is correct and immune to future widget-order changes. */ import { app } from "../../../scripts/app.js"; @@ -164,6 +187,9 @@ function createAudioWidget(node, nativeComboWidget) { const res=await api.fetchApi("/upload/audio",{method:"POST",body:fd}); if (!res.ok) throw new Error(await res.text()); const data=await res.json(); + if (data.deduped) { + console.log(`[AudioLoader] "${file.name}" is byte-identical to existing "${data.name}" — reusing it instead of uploading a duplicate.`); + } await loadAudioFile(data.name); } catch(e) { console.error("[AudioLoader] upload error",e); alert("Upload failed: "+e.message); } } @@ -344,6 +370,29 @@ app.registerExtension({ onNodeCreated?.apply(this, arguments); this.serialize_widgets = true; + // pitch_shift_semitones is a plain auto-generated FLOAT widget (it's + // just declared as an optional input in Python's INPUT_TYPES — no + // custom widget code needed for it). In some ComfyUI frontend builds + // a freshly-created optional numeric widget can end up with a null + // value instead of the declared default, and ComfyUI's own backend + // validator does an unconditional `float(value)` on any input whose + // key is present in the prompt — float(None) raises, which surfaces + // to the user as "failed to validate prompt" before the node ever + // runs (our own Python-side None-guards never get a chance to help, + // since that crash happens in ComfyUI's core, upstream of our code). + // Widgets are serialised via widget.serializeValue() when present, + // so pinning one down here guarantees a real number is always sent, + // regardless of what the widget's raw .value happens to be. + const pitchW = this.widgets?.find(w => w.name === "pitch_shift_semitones"); + if (pitchW) { + const safeNumber = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + if (!Number.isFinite(Number(pitchW.value))) pitchW.value = 0; + pitchW.serializeValue = () => safeNumber(pitchW.value); + } + const comboW = this.widgets?.find(w => w.name === "audio"); if (!comboW) { console.error("[AudioLoader] combo widget not found"); return; } @@ -394,11 +443,16 @@ app.registerExtension({ const vals = config.widgets_values ?? []; const fname = safeStr(vals[0]); - // Restore trim: prefer the serialised trim_json widget (vals[3]), - // fall back to the legacy axces2000_trim property for old workflows. + // Restore trim: LiteGraph already restores each widget's `.value` + // from widgets_values (matched by array position) before onConfigure + // runs, so we read the trim_json widget's own current value directly + // instead of indexing into widgets_values ourselves. This keeps trim + // restore correct no matter how many other widgets (e.g. the + // pitch_shift_semitones auto-widget) get added before it. + // Falls back to the legacy axces2000_trim property for old workflows. let ts=0, te=0; try { - const savedTrim = vals[3] ?? null; + const savedTrim = this._alTrimWidget?.value ?? null; if (savedTrim && savedTrim !== '{"s":0,"e":0}') { const p = JSON.parse(savedTrim); ts = p.s ?? 0; te = p.e ?? 0; diff --git a/js/audio_loader.js b/js/audio_loader.js index 09dfbf4..31617cf 100644 --- a/js/audio_loader.js +++ b/js/audio_loader.js @@ -1,5 +1,17 @@ /** - * AudioLoader Widget for ComfyUI v2.6 + * AudioLoader Widget for ComfyUI v2.9 + * + * v2.9 — pitch_shift_semitones (an auto-generated FLOAT widget) could reach + * the backend as `null` in some ComfyUI frontend builds, which crashes + * ComfyUI's own prompt validator (float(None)) before the node ever runs. + * Fixed by giving that widget a serializeValue() that always emits a real + * finite number. See the comment at its creation site below for details. + * + * v2.8 — The server's /upload/audio route now dedups by content checksum: + * re-uploading a file that's byte-identical to one already sitting under + * the same name (or same-name_N) reuses that existing file instead of + * writing a new numbered duplicate. Purely server-side; the only JS change + * is logging data.deduped to the console for visibility. * * trim_json is declared as a hidden input in Python's INPUT_TYPES. * ComfyUI passes hidden inputs automatically in the prompt — but only if @@ -8,6 +20,17 @@ * We use the cleanest available hook: override the node's getExtraInfo() * which ComfyUI calls when building the prompt, injecting trim_json there. * Fallback: also set it via a beforeQueued hook on the audio combo widget. + * + * v2.7 — Added `pitch_shift_semitones`, a plain optional FLOAT input on the + * Python side. ComfyUI auto-generates its widget from INPUT_TYPES just like + * `normalize`, so it needs no special handling here. The one thing that + * *does* need care: adding a new auto-widget shifts every widget's position + * in node.widgets / widgets_values. The old trim-restore code below read + * widgets_values by a hardcoded index (vals[3]), which would silently break + * as soon as any new widget was added anywhere before it. It now reads the + * trim_json widget's own `.value` instead — LiteGraph already restores each + * widget's value from widgets_values (by position) before onConfigure runs, + * so this is correct and immune to future widget-order changes. */ import { app } from "../../../scripts/app.js"; @@ -164,6 +187,9 @@ function createAudioWidget(node, nativeComboWidget) { const res=await api.fetchApi("/upload/audio",{method:"POST",body:fd}); if (!res.ok) throw new Error(await res.text()); const data=await res.json(); + if (data.deduped) { + console.log(`[AudioLoader] "${file.name}" is byte-identical to existing "${data.name}" — reusing it instead of uploading a duplicate.`); + } await loadAudioFile(data.name); } catch(e) { console.error("[AudioLoader] upload error",e); alert("Upload failed: "+e.message); } } @@ -344,6 +370,29 @@ app.registerExtension({ onNodeCreated?.apply(this, arguments); this.serialize_widgets = true; + // pitch_shift_semitones is a plain auto-generated FLOAT widget (it's + // just declared as an optional input in Python's INPUT_TYPES — no + // custom widget code needed for it). In some ComfyUI frontend builds + // a freshly-created optional numeric widget can end up with a null + // value instead of the declared default, and ComfyUI's own backend + // validator does an unconditional `float(value)` on any input whose + // key is present in the prompt — float(None) raises, which surfaces + // to the user as "failed to validate prompt" before the node ever + // runs (our own Python-side None-guards never get a chance to help, + // since that crash happens in ComfyUI's core, upstream of our code). + // Widgets are serialised via widget.serializeValue() when present, + // so pinning one down here guarantees a real number is always sent, + // regardless of what the widget's raw .value happens to be. + const pitchW = this.widgets?.find(w => w.name === "pitch_shift_semitones"); + if (pitchW) { + const safeNumber = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + if (!Number.isFinite(Number(pitchW.value))) pitchW.value = 0; + pitchW.serializeValue = () => safeNumber(pitchW.value); + } + const comboW = this.widgets?.find(w => w.name === "audio"); if (!comboW) { console.error("[AudioLoader] combo widget not found"); return; } @@ -394,11 +443,16 @@ app.registerExtension({ const vals = config.widgets_values ?? []; const fname = safeStr(vals[0]); - // Restore trim: prefer the serialised trim_json widget (vals[3]), - // fall back to the legacy axces2000_trim property for old workflows. + // Restore trim: LiteGraph already restores each widget's `.value` + // from widgets_values (matched by array position) before onConfigure + // runs, so we read the trim_json widget's own current value directly + // instead of indexing into widgets_values ourselves. This keeps trim + // restore correct no matter how many other widgets (e.g. the + // pitch_shift_semitones auto-widget) get added before it. + // Falls back to the legacy axces2000_trim property for old workflows. let ts=0, te=0; try { - const savedTrim = vals[3] ?? null; + const savedTrim = this._alTrimWidget?.value ?? null; if (savedTrim && savedTrim !== '{"s":0,"e":0}') { const p = JSON.parse(savedTrim); ts = p.s ?? 0; te = p.e ?? 0;