audio loader pitch change added and multiple sound files creation bug fixed

This commit is contained in:
axces2000
2026-07-16 17:11:47 +12:00
parent c6621c73c3
commit a080d5d8ac
3 changed files with 296 additions and 27 deletions
+180 -19
View File
@@ -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}
+58 -4
View File
@@ -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;
+58 -4
View File
@@ -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;