Merge pull request #57 from ryanontheinside/feat/midi-improvements
Feat/midi improvements
This commit is contained in:
+11
-4
@@ -150,7 +150,7 @@ from .nodes.flex.feature_extractors_audio import(
|
||||
)
|
||||
|
||||
from .nodes.flex.feature_extractors_midi import(
|
||||
MIDILoadAndExtract,
|
||||
MIDIFeatureExtractor,
|
||||
)
|
||||
|
||||
from .nodes.flex.feature_extractors_proximity import(
|
||||
@@ -298,6 +298,7 @@ from .nodes.flex.feature_modulation import (
|
||||
FeatureRenormalize,
|
||||
FeatureInterpolator,
|
||||
FeaturePeakDetector,
|
||||
FeatureInterpolateMulti,
|
||||
)
|
||||
|
||||
from .nodes.audio.flex_audio import (
|
||||
@@ -328,6 +329,9 @@ from .nodes.doom.doom import Doom_
|
||||
|
||||
from .audio_latent_blend import AudioLatentBlend, FlexlatentAudioBlend
|
||||
|
||||
|
||||
from .nodes.audio.midi_nodes import MIDIToAudio, MIDILoader
|
||||
|
||||
# Import external integrations
|
||||
from .external_integration import (
|
||||
HAS_ADVANCED_LIVE_PORTRAIT,
|
||||
@@ -374,6 +378,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"FlexlatentAudioBlend": FlexlatentAudioBlend,
|
||||
"FeatureInterpolator": FeatureInterpolator,
|
||||
"FeaturePeakDetector": FeaturePeakDetector,
|
||||
"FeatureInterpolateMulti": FeatureInterpolateMulti,
|
||||
|
||||
"Doom_": Doom_,
|
||||
"WhisperToPromptTravel": WhisperToPromptTravel,
|
||||
@@ -480,7 +485,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PitchRange": PitchRangeNode,
|
||||
"PitchRangePreset": PitchRangePresetNode,
|
||||
"PitchRangeByNoteNode": PitchRangeByNoteNode,
|
||||
"MIDILoadAndExtract": MIDILoadAndExtract,
|
||||
"MIDIFeatureExtractor": MIDIFeatureExtractor,
|
||||
"TimeFeatureNode": TimeFeatureNode,
|
||||
"FloatFeatureNode": FloatFeatureNode,
|
||||
"ManualFeatureNode": ManualFeatureNode,
|
||||
@@ -588,6 +593,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"ImageIntervalSelectPercentage":ImageIntervalSelectPercentage,
|
||||
"ImageIndexSelect": ImageIndexSelect,
|
||||
|
||||
"MIDIToAudio": MIDIToAudio,
|
||||
"MIDILoader": MIDILoader,
|
||||
}
|
||||
|
||||
|
||||
@@ -597,11 +604,11 @@ EXTENSION_WEB_DIRS = ["./web/extensions"]
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AudioSeparatorSimple": "Audio Separator",
|
||||
"ProximityVisualizer": "Preview Proximity",
|
||||
"EffectVisualizer": "Preview Effect",
|
||||
"EffectVisualizer": "Preview FeatureEffect",
|
||||
"PitchVisualizer": "Preview Pitch",
|
||||
"FlexVideoSpeed": "**BETA** Flex Video Speed",
|
||||
"FlexVideoFrameBlend": "**BETA**Flex Video Frame Blend",
|
||||
"AudioFeatureVisualizer": "Audio Feature Visualizer ***BETA***" ,
|
||||
"AudioFeatureVisualizer": "Audio Feature Visualizer ***BETA***",
|
||||
|
||||
"MIDILoadAndExtract": "MIDI Load & Feature Extract",
|
||||
"PitchRangeByNoteNode": "Pitch Range By Note",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+733
-1
@@ -1 +1,733 @@
|
||||
#TODO
|
||||
import torch
|
||||
import numpy as np
|
||||
import mido
|
||||
import os
|
||||
import folder_paths
|
||||
import math
|
||||
from .audio_nodes import AudioNodeBase
|
||||
from ...tooltips import apply_tooltips
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import shutil
|
||||
|
||||
@apply_tooltips
|
||||
class MIDIToAudio(AudioNodeBase):
|
||||
"""Converts MIDI data to audio format compatible with ComfyUI's audio system."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"midi": ("MIDI",),
|
||||
"instrument_type": (["Piano", "Synth", "Bass", "Drums"],),
|
||||
"sample_rate": ("INT", {
|
||||
"default": 44100,
|
||||
"min": 8000,
|
||||
"max": 192000,
|
||||
"step": 1
|
||||
}),
|
||||
"volume": ("FLOAT", {
|
||||
"default": 0.5,
|
||||
"min": 0.0,
|
||||
"max": 1.0,
|
||||
"step": 0.01
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
FUNCTION = "convert_midi_to_audio"
|
||||
CATEGORY = "RyanOnTheInside/Audio/MIDI"
|
||||
DESCRIPTION = "This will produce rudimentary approximations of MIDI notes for quick reference and nothing more. May or may not get the notes right, especially for drums."
|
||||
|
||||
def generate_instrument_waveform(self, frequency, t, instrument_type, envelope, note_number=60):
|
||||
"""Generate waveform based on the selected instrument type"""
|
||||
if instrument_type == "Piano":
|
||||
# Piano-like sound with some harmonics
|
||||
waveform = 0.5 * np.sin(2 * np.pi * frequency * t) # Fundamental
|
||||
waveform += 0.2 * np.sin(2 * np.pi * frequency * 2 * t) # 1st harmonic (octave)
|
||||
waveform += 0.1 * np.sin(2 * np.pi * frequency * 3 * t) # 2nd harmonic
|
||||
waveform += 0.05 * np.sin(2 * np.pi * frequency * 4 * t) # 3rd harmonic
|
||||
# Add faster decay for piano-like sound
|
||||
decay = np.exp(-t * 3)
|
||||
return waveform * envelope * decay
|
||||
|
||||
elif instrument_type == "Bass":
|
||||
# Bass with more low frequencies
|
||||
waveform = 0.6 * np.sin(2 * np.pi * frequency * t) # Fundamental
|
||||
waveform += 0.3 * np.sin(2 * np.pi * frequency * 2 * t) # 1st harmonic
|
||||
# Add some distortion for bass character
|
||||
waveform = np.tanh(waveform * 1.5) * 0.7
|
||||
# Slower decay for bass
|
||||
decay = np.exp(-t * 2)
|
||||
return waveform * envelope * decay
|
||||
|
||||
elif instrument_type == "Drums":
|
||||
# Use standard MIDI drum mappings (channel 10)
|
||||
# Define custom synthesis for each drum sound based on note number
|
||||
|
||||
# Bass/Kick Drums
|
||||
if note_number in [35, 36]: # Bass Drum 2, Bass Drum 1
|
||||
# Low frequency sine with very fast decay
|
||||
waveform = np.sin(2 * np.pi * 60 * t) # Fixed low frequency for kick
|
||||
waveform += 0.2 * np.sin(2 * np.pi * 90 * t) # Add some mid tone
|
||||
decay = np.exp(-t * 20) # Very quick decay
|
||||
return waveform * envelope * decay
|
||||
|
||||
# Snare Drums
|
||||
elif note_number in [38, 40]: # Acoustic Snare, Electric Snare
|
||||
# Mix of sine wave and noise
|
||||
waveform = 0.3 * np.sin(2 * np.pi * 150 * t) # Mid frequency tone
|
||||
noise = np.random.rand(len(t)) * 2 - 1 # White noise
|
||||
decay = np.exp(-t * 15) # Fast decay
|
||||
return (waveform + 0.7 * noise) * envelope * decay
|
||||
|
||||
# Hi-Hats
|
||||
elif note_number in [42, 44, 46]: # Closed, Pedal, Open Hi-Hats
|
||||
# Mostly noise with different decay times
|
||||
noise = np.random.rand(len(t)) * 2 - 1
|
||||
# Different decay times based on hi-hat type
|
||||
if note_number == 42: # Closed Hi-Hat
|
||||
decay = np.exp(-t * 30) # Very short
|
||||
elif note_number == 44: # Pedal Hi-Hat
|
||||
decay = np.exp(-t * 25) # Short
|
||||
else: # Open Hi-Hat
|
||||
decay = np.exp(-t * 10) # Longer
|
||||
|
||||
# Add some high frequency sine for metallic character
|
||||
waveform = noise + 0.1 * np.sin(2 * np.pi * 800 * t)
|
||||
|
||||
return waveform * envelope * decay
|
||||
|
||||
# Toms
|
||||
elif note_number in [41, 43, 45, 47, 48, 50]: # Various Toms
|
||||
# Pitched sine waves with medium decay
|
||||
if note_number in [41, 43]: # Floor Toms
|
||||
tom_freq = 80 # Low frequency
|
||||
elif note_number in [45, 47]: # Mid Toms
|
||||
tom_freq = 120 # Mid frequency
|
||||
else: # High Toms
|
||||
tom_freq = 180 # Higher frequency
|
||||
|
||||
waveform = np.sin(2 * np.pi * tom_freq * t)
|
||||
decay = np.exp(-t * 12)
|
||||
return waveform * envelope * decay
|
||||
|
||||
# Cymbals
|
||||
elif note_number in [49, 51, 52, 53, 55, 57]: # Crash and Ride Cymbals
|
||||
# Complex noise with slow decay
|
||||
noise = np.random.rand(len(t)) * 2 - 1
|
||||
# Add some high frequencies for metallic sound
|
||||
for i in range(3, 10):
|
||||
noise += 0.1 / i * np.sin(2 * np.pi * 500 * i * t)
|
||||
|
||||
# Longer decay for cymbals
|
||||
decay = np.exp(-t * 4)
|
||||
return noise * envelope * decay
|
||||
|
||||
# Other percussion (default case)
|
||||
else:
|
||||
# Generic percussion sound based on frequency
|
||||
noise = np.random.rand(len(t)) * 2 - 1
|
||||
waveform = 0.5 * np.sin(2 * np.pi * frequency * t) + 0.5 * noise
|
||||
decay = np.exp(-t * 10)
|
||||
return waveform * envelope * decay
|
||||
|
||||
else: # "Synth" (default)
|
||||
# Simple synth with multiple waveforms
|
||||
waveform = 0.4 * np.sin(2 * np.pi * frequency * t) # Sine
|
||||
# Add square wave component
|
||||
square = 0.3 * np.sign(np.sin(2 * np.pi * frequency * t))
|
||||
# Add sawtooth component
|
||||
sawtooth = 0.3 * ((2 * (frequency * t - np.floor(0.5 + frequency * t))) % 2)
|
||||
|
||||
return (waveform + square + sawtooth) * envelope
|
||||
|
||||
def convert_midi_to_audio(self, midi, instrument_type, sample_rate, volume):
|
||||
try:
|
||||
# Get tempo from MIDI file (default to 120 BPM if not specified)
|
||||
tempo = 500000 # Default tempo (microseconds per quarter note)
|
||||
for track in midi.tracks:
|
||||
for msg in track:
|
||||
if msg.type == 'set_tempo':
|
||||
tempo = msg.tempo
|
||||
break
|
||||
|
||||
# Calculate total duration in seconds
|
||||
total_time = 0
|
||||
last_event_time = 0
|
||||
has_notes = False
|
||||
|
||||
for track in midi.tracks:
|
||||
track_time = 0
|
||||
notes_in_track = False
|
||||
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
track_time += msg.time
|
||||
if msg.type == 'note_on' and hasattr(msg, 'velocity') and msg.velocity > 0:
|
||||
notes_in_track = True
|
||||
has_notes = True
|
||||
last_event_time = track_time
|
||||
elif (msg.type == 'note_off' or (msg.type == 'note_on' and hasattr(msg, 'velocity') and msg.velocity == 0)):
|
||||
last_event_time = track_time
|
||||
|
||||
total_time = max(total_time, track_time)
|
||||
|
||||
# If we found notes, use the last note event time plus some padding
|
||||
if has_notes:
|
||||
total_time = last_event_time + 240 # Add a small buffer (240 ticks) for note releases
|
||||
|
||||
# Convert ticks to seconds using tempo
|
||||
seconds_per_tick = tempo / (midi.ticks_per_beat * 1000000.0)
|
||||
total_time = total_time * seconds_per_tick
|
||||
|
||||
# Ensure we have some duration
|
||||
total_time = max(total_time, 0.5) # At least 0.5 second
|
||||
|
||||
print(f"MIDI duration: {total_time:.2f} seconds")
|
||||
|
||||
# Create empty audio buffer
|
||||
audio_length = int(total_time * sample_rate)
|
||||
audio_buffer = np.zeros(audio_length)
|
||||
|
||||
# Process MIDI messages from all tracks
|
||||
for track in midi.tracks:
|
||||
current_time = 0
|
||||
active_notes = {} # {note: (start_time, velocity)}
|
||||
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
current_time += msg.time * seconds_per_tick
|
||||
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
# Start a new note
|
||||
active_notes[msg.note] = (current_time, msg.velocity)
|
||||
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||
# End a note
|
||||
if msg.note in active_notes:
|
||||
start_time, velocity = active_notes[msg.note]
|
||||
duration = current_time - start_time
|
||||
|
||||
# Generate note audio using instrument-specific synthesis
|
||||
frequency = 440.0 * (2.0 ** ((msg.note - 69) / 12.0)) # Convert MIDI note to frequency
|
||||
note_samples = int(duration * sample_rate)
|
||||
if note_samples > 0:
|
||||
t = np.linspace(0, duration, note_samples, False)
|
||||
# Simple envelope to avoid clicks
|
||||
envelope = np.ones_like(t)
|
||||
attack = int(0.01 * sample_rate)
|
||||
release = int(0.01 * sample_rate)
|
||||
if len(envelope) > attack:
|
||||
envelope[:attack] = np.linspace(0, 1, attack)
|
||||
if len(envelope) > release:
|
||||
envelope[-release:] = np.linspace(1, 0, release)
|
||||
|
||||
# Generate waveform based on instrument type
|
||||
note_audio = self.generate_instrument_waveform(
|
||||
frequency, t, instrument_type, envelope, msg.note
|
||||
) * (velocity / 127.0) * volume
|
||||
|
||||
# Add to buffer with proper timing
|
||||
start_idx = int(start_time * sample_rate)
|
||||
end_idx = start_idx + len(note_audio)
|
||||
if end_idx > len(audio_buffer):
|
||||
audio_buffer = np.pad(audio_buffer, (0, end_idx - len(audio_buffer)))
|
||||
audio_buffer[start_idx:end_idx] += note_audio
|
||||
|
||||
del active_notes[msg.note]
|
||||
|
||||
# Normalize audio if we have any content
|
||||
if len(audio_buffer) > 0 and np.max(np.abs(audio_buffer)) > 0:
|
||||
audio_buffer = audio_buffer / np.max(np.abs(audio_buffer))
|
||||
|
||||
# Convert to stereo
|
||||
audio_buffer = np.stack([audio_buffer, audio_buffer])
|
||||
|
||||
# Convert to torch tensor
|
||||
audio_tensor = torch.from_numpy(audio_buffer).float()
|
||||
|
||||
print(f"Generated audio length: {len(audio_buffer[0])/sample_rate:.2f} seconds")
|
||||
|
||||
return ({
|
||||
"waveform": audio_tensor.unsqueeze(0), # Add batch dimension
|
||||
"sample_rate": sample_rate
|
||||
},)
|
||||
|
||||
except Exception as e:
|
||||
# If anything goes wrong, return empty audio
|
||||
print(f"Error in MIDIToAudio: {str(e)}")
|
||||
empty_audio = torch.zeros((1, 2, 1)) # 1 batch, 2 channels, 1 sample
|
||||
return ({
|
||||
"waveform": empty_audio,
|
||||
"sample_rate": sample_rate
|
||||
},)
|
||||
|
||||
def calculate_midi_total_measures(midi_data):
|
||||
"""Calculate the total number of measures in the MIDI file"""
|
||||
# Get time signature (defaults to 4/4)
|
||||
time_sig_numerator = 4
|
||||
time_sig_denominator = 4
|
||||
|
||||
for track in midi_data.tracks:
|
||||
for msg in track:
|
||||
if msg.type == 'time_signature':
|
||||
time_sig_numerator = msg.numerator
|
||||
time_sig_denominator = msg.denominator
|
||||
break
|
||||
|
||||
# Calculate total ticks
|
||||
total_ticks = 0
|
||||
for track in midi_data.tracks:
|
||||
track_ticks = 0
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
track_ticks += msg.time
|
||||
total_ticks = max(total_ticks, track_ticks)
|
||||
|
||||
# Calculate ticks per measure
|
||||
ticks_per_beat = midi_data.ticks_per_beat
|
||||
ticks_per_measure = ticks_per_beat * 4 * (time_sig_numerator / time_sig_denominator)
|
||||
|
||||
# Calculate total measures
|
||||
total_measures = math.ceil(total_ticks / ticks_per_measure)
|
||||
return total_measures, time_sig_numerator, time_sig_denominator
|
||||
|
||||
def convert_measures_to_ticks_range(midi_data, start_measure, start_beat, end_measure, end_beat):
|
||||
"""Convert musical measure range to MIDI tick range"""
|
||||
# Get time signature (defaults to 4/4)
|
||||
time_sig_numerator = 4
|
||||
time_sig_denominator = 4
|
||||
|
||||
for track in midi_data.tracks:
|
||||
for msg in track:
|
||||
if msg.type == 'time_signature':
|
||||
time_sig_numerator = msg.numerator
|
||||
time_sig_denominator = msg.denominator
|
||||
break
|
||||
|
||||
# Calculate ticks per beat and measure
|
||||
ticks_per_beat = midi_data.ticks_per_beat
|
||||
beats_per_measure = time_sig_numerator
|
||||
|
||||
# Adjust start measure/beat to 0-indexed for calculation
|
||||
start_measure_index = start_measure - 1
|
||||
start_beat_index = start_beat - 1
|
||||
|
||||
# Calculate start tick
|
||||
start_tick = (start_measure_index * beats_per_measure + start_beat_index) * ticks_per_beat
|
||||
|
||||
# Calculate end tick
|
||||
if end_measure > 0:
|
||||
end_measure_index = end_measure - 1
|
||||
end_beat_index = end_beat - 1
|
||||
end_tick = (end_measure_index * beats_per_measure + end_beat_index) * ticks_per_beat
|
||||
else:
|
||||
end_tick = float('inf')
|
||||
|
||||
return start_tick, end_tick
|
||||
|
||||
@apply_tooltips
|
||||
class MIDILoader:
|
||||
"""Loads MIDI files for processing in ComfyUI with options for selecting specific measures."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"midi_file": (folder_paths.get_filename_list("midi_files"),),
|
||||
"track_selection": (["all"],),
|
||||
"start_measure": ("INT", {"default": 1, "min": 1, "step": 1}),
|
||||
"start_beat": ("INT", {"default": 1, "min": 1, "step": 1}),
|
||||
"end_measure": ("INT", {"default": 0, "min": 0, "step": 1, "tooltip": "End measure (0 = all remaining measures)"}),
|
||||
"end_beat": ("INT", {"default": 1, "min": 1, "step": 1, "tootip": "End beat"})
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MIDI",)
|
||||
FUNCTION = "load_midi"
|
||||
CATEGORY = "RyanOnTheInside/Audio/MIDI"
|
||||
|
||||
def load_midi(self, midi_file, track_selection, start_measure=1, start_beat=1, end_measure=0, end_beat=1):
|
||||
try:
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
raise FileNotFoundError(f"MIDI file not found: {midi_file}")
|
||||
|
||||
# Load the MIDI file
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
|
||||
# Apply track selection if not "all"
|
||||
if track_selection != "all":
|
||||
track_index_str = track_selection.split(':')[0].strip()
|
||||
if track_index_str.isdigit():
|
||||
track_index = int(track_index_str)
|
||||
# Create a new MIDI file with only the selected track
|
||||
selected_midi = mido.MidiFile(ticks_per_beat=midi_data.ticks_per_beat)
|
||||
|
||||
# First, add any metadata tracks (typically track 0 in type 1 MIDI files)
|
||||
if midi_data.type == 1 and track_index > 0:
|
||||
selected_midi.tracks.append(midi_data.tracks[0])
|
||||
|
||||
# Add the selected track
|
||||
if track_index < len(midi_data.tracks):
|
||||
selected_midi.tracks.append(midi_data.tracks[track_index])
|
||||
|
||||
midi_data = selected_midi
|
||||
|
||||
# Apply measure slicing if needed
|
||||
if (start_measure > 1 or start_beat > 1 or end_measure > 0) and midi_data.tracks:
|
||||
# Get total measures in the file for validation
|
||||
total_measures, _, _ = calculate_midi_total_measures(midi_data)
|
||||
|
||||
# If end measure is 0, use the total measures
|
||||
if end_measure == 0:
|
||||
end_measure = total_measures
|
||||
|
||||
# Convert musical measures to ticks
|
||||
start_tick, end_tick = convert_measures_to_ticks_range(midi_data, start_measure, start_beat, end_measure, end_beat)
|
||||
|
||||
# The total duration in ticks is EXACTLY end_tick - start_tick
|
||||
total_tick_duration = end_tick - start_tick
|
||||
|
||||
# Create a new MIDI file with the selected measures
|
||||
trimmed_midi = mido.MidiFile(ticks_per_beat=midi_data.ticks_per_beat)
|
||||
|
||||
for track in midi_data.tracks:
|
||||
new_track = mido.MidiTrack()
|
||||
trimmed_midi.tracks.append(new_track)
|
||||
|
||||
# Copy important metadata messages first
|
||||
for msg in track:
|
||||
if not hasattr(msg, 'time'):
|
||||
if msg.type in ['track_name', 'time_signature', 'key_signature', 'set_tempo']:
|
||||
new_track.append(msg.copy())
|
||||
|
||||
# Collect events within the time range
|
||||
note_events = []
|
||||
active_notes = {} # {note_num: tick_time_started}
|
||||
notes_active_before_range = {} # Track notes that started before our range
|
||||
current_tick = 0
|
||||
|
||||
# First pass: collect all note on/off events, tracking notes that cross boundaries
|
||||
for msg in track:
|
||||
if not hasattr(msg, 'time'):
|
||||
continue
|
||||
|
||||
current_tick += msg.time
|
||||
|
||||
# Handle note_on events
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
# Note starting before our range
|
||||
if current_tick < start_tick:
|
||||
notes_active_before_range[msg.note] = current_tick
|
||||
# Note starting within our range
|
||||
elif current_tick <= end_tick:
|
||||
active_notes[msg.note] = current_tick
|
||||
note_events.append((current_tick, msg.copy()))
|
||||
|
||||
# Handle note_off events
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||
# Handle notes that started before our range but end within it
|
||||
if msg.note in notes_active_before_range:
|
||||
if current_tick >= start_tick and current_tick <= end_tick:
|
||||
# Create a new note_on at the start boundary
|
||||
new_note_on = mido.Message('note_on', note=msg.note, velocity=64, time=0)
|
||||
note_events.append((start_tick, new_note_on))
|
||||
# Add the note_off event
|
||||
note_events.append((current_tick, msg.copy()))
|
||||
del notes_active_before_range[msg.note]
|
||||
|
||||
# Handle notes that started within our range
|
||||
elif msg.note in active_notes:
|
||||
# If note ends within our range, add the note_off event
|
||||
if current_tick <= end_tick:
|
||||
note_events.append((current_tick, msg.copy()))
|
||||
# If note would end outside our range, add a note_off at the end boundary
|
||||
else:
|
||||
new_note_off = mido.Message('note_off', note=msg.note, velocity=0, time=0)
|
||||
note_events.append((end_tick, new_note_off))
|
||||
del active_notes[msg.note]
|
||||
|
||||
# Include other MIDI events within the time range
|
||||
elif current_tick >= start_tick and current_tick <= end_tick:
|
||||
if msg.type in ['set_tempo', 'control_change', 'program_change', 'pitchwheel']:
|
||||
note_events.append((current_tick, msg.copy()))
|
||||
|
||||
# Add note_off events at end boundary for any notes still active at the end
|
||||
for note in active_notes:
|
||||
new_note_off = mido.Message('note_off', note=note, velocity=0, time=0)
|
||||
note_events.append((end_tick, new_note_off))
|
||||
|
||||
# Sort events by tick time
|
||||
note_events.sort(key=lambda x: x[0])
|
||||
|
||||
# Add events to the new track with adjusted timing
|
||||
prev_tick = start_tick
|
||||
|
||||
for tick, msg in note_events:
|
||||
# Adjust timing relative to previous event
|
||||
adjusted_msg = msg.copy()
|
||||
adjusted_msg.time = tick - prev_tick
|
||||
prev_tick = tick
|
||||
|
||||
new_track.append(adjusted_msg)
|
||||
|
||||
# Calculate how many ticks remain until the exact end_tick
|
||||
remaining_ticks = start_tick + total_tick_duration - prev_tick
|
||||
|
||||
# Add end of track marker at exactly the right position
|
||||
end_track = mido.MetaMessage('end_of_track')
|
||||
end_track.time = max(0, remaining_ticks)
|
||||
new_track.append(end_track)
|
||||
|
||||
midi_data = trimmed_midi
|
||||
|
||||
return (midi_data,)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error loading MIDI file: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def analyze_midi(cls, midi_path, start_measure=1, start_beat=1, end_measure=0, end_beat=1):
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
|
||||
# Get the total measures
|
||||
total_measures, time_sig_num, time_sig_denom = calculate_midi_total_measures(midi_data)
|
||||
|
||||
# If end measure is 0, use the total measures
|
||||
if end_measure == 0:
|
||||
end_measure = total_measures
|
||||
|
||||
# Convert measures to ticks if measure filtering is applied
|
||||
if start_measure > 1 or start_beat > 1 or end_measure < total_measures:
|
||||
start_tick, end_tick = convert_measures_to_ticks_range(midi_data, start_measure, start_beat, end_measure, end_beat)
|
||||
else:
|
||||
start_tick = 0
|
||||
end_tick = float('inf')
|
||||
|
||||
tracks = ["all"]
|
||||
all_notes = set()
|
||||
track_notes = {}
|
||||
for i, track in enumerate(midi_data.tracks):
|
||||
track_notes[str(i)] = set()
|
||||
current_tick = 0
|
||||
|
||||
# Keep track of notes that start within range
|
||||
active_notes = set()
|
||||
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
current_tick += msg.time
|
||||
|
||||
# Only consider notes within our measure range
|
||||
if current_tick >= start_tick and current_tick <= end_tick:
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
track_notes[str(i)].add(msg.note)
|
||||
all_notes.add(msg.note)
|
||||
active_notes.add(msg.note)
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||
if msg.note in active_notes:
|
||||
active_notes.remove(msg.note)
|
||||
|
||||
if len(track_notes[str(i)]) == 0:
|
||||
tracks.append(f"{i}: (Empty)")
|
||||
else:
|
||||
tracks.append(f"{i}: {getattr(track, 'name', '') or f'Track {i}'}")
|
||||
|
||||
return {
|
||||
"tracks": tracks,
|
||||
"all_notes": ",".join(map(str, sorted(set(all_notes)))),
|
||||
"track_notes": {k: ",".join(map(str, sorted(v))) for k, v in track_notes.items()},
|
||||
"total_measures": total_measures,
|
||||
"time_signature": f"{time_sig_num}/{time_sig_denom}"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, midi_file, track_selection, start_measure=1, start_beat=1, end_measure=0, end_beat=1):
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.isfile(midi_path):
|
||||
return f"MIDI file not found: {midi_file}"
|
||||
|
||||
# Check if the file has a .mid or .midi extension
|
||||
if not midi_file.lower().endswith(('.mid', '.midi')):
|
||||
return f"Invalid file type. Expected .mid or .midi file, got: {midi_file}"
|
||||
|
||||
if start_measure < 1:
|
||||
return f"Start measure must be at least 1, got: {start_measure}"
|
||||
|
||||
if start_beat < 1:
|
||||
return f"Start beat must be at least 1, got: {start_beat}"
|
||||
|
||||
if end_measure < 0:
|
||||
return f"End measure cannot be negative, got: {end_measure}"
|
||||
|
||||
if end_beat < 1:
|
||||
return f"End beat must be at least 1, got: {end_beat}"
|
||||
|
||||
# Check if the start measure is valid for this MIDI file
|
||||
try:
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
total_measures, _, _ = calculate_midi_total_measures(midi_data)
|
||||
|
||||
if start_measure > total_measures:
|
||||
return f"Start measure {start_measure} exceeds the total measures in the file ({total_measures})"
|
||||
|
||||
if end_measure > 0 and end_measure > total_measures:
|
||||
return f"End measure {end_measure} exceeds the total measures in the file ({total_measures})"
|
||||
|
||||
if end_measure > 0 and end_measure < start_measure:
|
||||
return f"End measure {end_measure} cannot be less than start measure {start_measure}"
|
||||
|
||||
if end_measure == start_measure and end_beat < start_beat:
|
||||
return f"When start and end measures are the same, end beat {end_beat} cannot be less than start beat {start_beat}"
|
||||
except Exception as e:
|
||||
return f"Error validating MIDI file: {str(e)}"
|
||||
|
||||
return True
|
||||
|
||||
# Server routes for MIDI file handling
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
@routes.post('/get_track_notes')
|
||||
async def get_track_notes(request):
|
||||
data = await request.json()
|
||||
midi_file = data.get('midi_file')
|
||||
start_measure = data.get('start_measure', 1)
|
||||
start_beat = data.get('start_beat', 1)
|
||||
end_measure = data.get('end_measure', 0)
|
||||
end_beat = data.get('end_beat', 1)
|
||||
|
||||
if not midi_file:
|
||||
return web.json_response({"error": "Missing required parameters"}, status=400)
|
||||
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
return web.json_response({"error": "MIDI file not found"}, status=404)
|
||||
|
||||
# Analyze MIDI file
|
||||
analysis = MIDILoader.analyze_midi(midi_path, start_measure, start_beat, end_measure, end_beat)
|
||||
return web.json_response(analysis)
|
||||
|
||||
@routes.post('/upload_midi')
|
||||
async def upload_midi(request):
|
||||
data = await request.post()
|
||||
midi_file = data['file']
|
||||
|
||||
if midi_file and midi_file.filename:
|
||||
safe_filename = os.path.basename(midi_file.filename)
|
||||
|
||||
midi_dir = folder_paths.get_folder_paths("midi_files")[0]
|
||||
|
||||
if not os.path.exists(midi_dir):
|
||||
os.makedirs(midi_dir, exist_ok=True)
|
||||
|
||||
midi_path = os.path.join(midi_dir, safe_filename)
|
||||
|
||||
with open(midi_path, 'wb') as f:
|
||||
shutil.copyfileobj(midi_file.file, f)
|
||||
|
||||
midi_files = folder_paths.get_filename_list("midi_files")
|
||||
analysis = MIDILoader.analyze_midi(midi_path)
|
||||
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"uploaded_file": safe_filename,
|
||||
"midi_files": midi_files,
|
||||
"analysis": analysis
|
||||
})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "No file uploaded"}, status=400)
|
||||
|
||||
@routes.post('/refresh_midi_data')
|
||||
async def refresh_midi_data(request):
|
||||
data = await request.json()
|
||||
midi_file = data.get('midi_file')
|
||||
track_selection = data.get('track_selection')
|
||||
start_measure = data.get('start_measure', 1)
|
||||
start_beat = data.get('start_beat', 1)
|
||||
end_measure = data.get('end_measure', 0)
|
||||
end_beat = data.get('end_beat', 1)
|
||||
|
||||
if not midi_file:
|
||||
return web.json_response({"error": "Missing required parameters"}, status=400)
|
||||
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
return web.json_response({"error": "MIDI file not found"}, status=404)
|
||||
|
||||
# Load the full MIDI data
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
|
||||
# Get total measures information
|
||||
total_measures, time_sig_num, time_sig_denom = calculate_midi_total_measures(midi_data)
|
||||
|
||||
# If end measure is 0, use the total measures
|
||||
if end_measure == 0:
|
||||
end_measure = total_measures
|
||||
|
||||
# Apply track selection and measure filtering
|
||||
all_notes = set()
|
||||
|
||||
# Convert measures to ticks
|
||||
if start_measure > 1 or start_beat > 1 or end_measure < total_measures:
|
||||
start_tick, end_tick = convert_measures_to_ticks_range(midi_data, start_measure, start_beat, end_measure, end_beat)
|
||||
else:
|
||||
start_tick = 0
|
||||
end_tick = float('inf')
|
||||
|
||||
# Process tracks to collect notes within the time range
|
||||
track_notes = {}
|
||||
for i, track in enumerate(midi_data.tracks):
|
||||
track_notes[str(i)] = set()
|
||||
current_tick = 0
|
||||
|
||||
# Keep track of notes that start within range
|
||||
active_notes = set()
|
||||
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
current_tick += msg.time
|
||||
|
||||
# Only consider note_on messages with velocity > 0 (actual notes being played)
|
||||
if msg.type == 'note_on' and hasattr(msg, 'velocity') and msg.velocity > 0:
|
||||
# Check if this note is within our measure range
|
||||
if current_tick >= start_tick and current_tick < end_tick:
|
||||
note = msg.note
|
||||
track_notes[str(i)].add(note)
|
||||
active_notes.add(note)
|
||||
# If all tracks are selected or the current track matches selection, add to all_notes
|
||||
if track_selection == "all" or track_selection.startswith(f"{i}:"):
|
||||
all_notes.add(note)
|
||||
|
||||
# Remove notes from active set when they're turned off, but don't remove from track_notes
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and hasattr(msg, 'velocity') and msg.velocity == 0):
|
||||
if msg.note in active_notes:
|
||||
active_notes.remove(msg.note)
|
||||
|
||||
# If a specific track is selected but we didn't find any notes, it might be because
|
||||
# track selection was changed after note filtering
|
||||
if track_selection != "all" and not all_notes:
|
||||
track_index = track_selection.split(':')[0]
|
||||
if track_index.isdigit() and track_index in track_notes:
|
||||
all_notes = track_notes[track_index]
|
||||
|
||||
# Format and return the response
|
||||
return web.json_response({
|
||||
"tracks": ["all"] + [f"{i}: {getattr(track, 'name', '') or f'Track {i}'}" for i, track in enumerate(midi_data.tracks)],
|
||||
"all_notes": ",".join(map(str, sorted(all_notes))),
|
||||
"track_notes": {k: ",".join(map(str, sorted(v))) for k, v in track_notes.items()},
|
||||
"total_measures": total_measures,
|
||||
"time_signature": f"{time_sig_num}/{time_sig_denom}"
|
||||
})
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MIDIToAudio": MIDIToAudio,
|
||||
"MIDILoader": MIDILoader
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MIDIToAudio": "MIDI to Audio",
|
||||
"MIDILoader": "MIDI Loader"
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import mido
|
||||
import os
|
||||
import folder_paths
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import shutil
|
||||
from .features_midi import MIDIFeature
|
||||
from .feature_extractors import FeatureExtractorBase
|
||||
from ...tooltips import apply_tooltips
|
||||
|
||||
@apply_tooltips
|
||||
class MIDILoadAndExtract(FeatureExtractorBase):
|
||||
class MIDIFeatureExtractor(FeatureExtractorBase):
|
||||
"""Extract musical features from MIDI data based on selected notes from a piano interface.
|
||||
|
||||
This node connects to a MIDILoader node to receive MIDI data and extract features
|
||||
based on selected notes. Features can be used to modulate other parameters in the workflow.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def feature_type(cls) -> type[MIDIFeature]:
|
||||
return MIDIFeature
|
||||
@@ -18,27 +21,57 @@ class MIDILoadAndExtract(FeatureExtractorBase):
|
||||
def INPUT_TYPES(cls):
|
||||
parent_inputs = super().INPUT_TYPES()["required"]
|
||||
parent_inputs["extraction_method"] = (MIDIFeature.get_extraction_methods(),)
|
||||
parent_inputs["frame_count"] = ("INT", {"default": 0, "min": 0}) # Changed default to 0 for auto-calculation
|
||||
return {
|
||||
"required": {
|
||||
**parent_inputs,
|
||||
"midi_file": (folder_paths.get_filename_list("midi_files"),),
|
||||
"track_selection": (["all"],),
|
||||
"midi": ("MIDI",),
|
||||
"chord_only": ("BOOLEAN", {"default": False}),
|
||||
"notes": ("STRING", {"default": ""}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MIDI", "FEATURE")
|
||||
RETURN_TYPES = ("MIDI", "FEATURE", "INT")
|
||||
RETURN_NAMES = ("midi", "feature", "frame_count")
|
||||
FUNCTION = "process_midi"
|
||||
|
||||
CATEGORY = "RyanOnTheInside/Audio/Features"
|
||||
|
||||
def process_midi(self, midi_file, track_selection, notes, extraction_method, frame_rate, frame_count, width, height, chord_only=False):
|
||||
def calculate_target_frame_count(self, midi, frame_rate, frame_count):
|
||||
"""Calculate the target frame count based on MIDI duration and specified frame count"""
|
||||
if frame_count > 0:
|
||||
return frame_count
|
||||
|
||||
# Calculate total duration in seconds
|
||||
total_time = 0
|
||||
for track in midi.tracks:
|
||||
track_time = 0
|
||||
for msg in track:
|
||||
if hasattr(msg, 'time'):
|
||||
track_time += msg.time
|
||||
total_time = max(total_time, track_time)
|
||||
|
||||
# Get tempo (default to 120 BPM if not specified)
|
||||
tempo = 500000 # Default tempo in microseconds per quarter note
|
||||
for track in midi.tracks:
|
||||
for msg in track:
|
||||
if msg.type == 'set_tempo':
|
||||
tempo = msg.tempo
|
||||
break
|
||||
|
||||
# Convert ticks to seconds
|
||||
seconds_per_tick = tempo / (midi.ticks_per_beat * 1000000.0)
|
||||
duration_seconds = total_time * seconds_per_tick
|
||||
|
||||
# Ensure we have some duration
|
||||
duration_seconds = max(duration_seconds, 1.0) # At least 1 second
|
||||
|
||||
# Calculate frame count
|
||||
return max(1, int(duration_seconds * frame_rate))
|
||||
|
||||
def process_midi(self, midi, notes, extraction_method, frame_rate, frame_count, width, height, chord_only=False):
|
||||
try:
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
raise FileNotFoundError(f"MIDI file not found: {midi_file}")
|
||||
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
# Calculate appropriate frame count
|
||||
target_frame_count = self.calculate_target_frame_count(midi, frame_rate, frame_count)
|
||||
|
||||
selected_notes = [int(n.strip()) for n in notes.split(',') if n.strip().isdigit()]
|
||||
|
||||
@@ -47,10 +80,10 @@ class MIDILoadAndExtract(FeatureExtractorBase):
|
||||
|
||||
feature = MIDIFeature(
|
||||
f"midi_{internal_attribute}",
|
||||
midi_data,
|
||||
midi,
|
||||
internal_attribute,
|
||||
frame_rate,
|
||||
frame_count,
|
||||
target_frame_count, # Use calculated frame count
|
||||
width,
|
||||
height,
|
||||
notes=selected_notes,
|
||||
@@ -59,119 +92,28 @@ class MIDILoadAndExtract(FeatureExtractorBase):
|
||||
|
||||
feature.extract()
|
||||
|
||||
return (midi_data, feature)
|
||||
return (midi, feature, target_frame_count)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error processing MIDI file: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def analyze_midi(cls, midi_path):
|
||||
midi_data = mido.MidiFile(midi_path)
|
||||
tracks = ["all"]
|
||||
all_notes = set()
|
||||
track_notes = {}
|
||||
for i, track in enumerate(midi_data.tracks):
|
||||
track_notes[str(i)] = set()
|
||||
for msg in track:
|
||||
if msg.type == 'note_on':
|
||||
track_notes[str(i)].add(msg.note)
|
||||
all_notes.add(msg.note)
|
||||
if len(track_notes[str(i)]) == 0:
|
||||
tracks.append(f"{i}: (Empty)")
|
||||
else:
|
||||
tracks.append(f"{i}: {track.name or f'Track {i}'}")
|
||||
|
||||
return {
|
||||
"tracks": tracks,
|
||||
"all_notes": ",".join(map(str, sorted(set(all_notes)))),
|
||||
"track_notes": {k: ",".join(map(str, sorted(v))) for k, v in track_notes.items()}
|
||||
}
|
||||
raise RuntimeError(f"Error processing MIDI: {type(e).__name__}: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, midi_file, track_selection, notes, extraction_method, frame_rate, video_frames):
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.isfile(midi_path):
|
||||
return f"MIDI file not found: {midi_file}"
|
||||
|
||||
# Check if the file has a .mid or .midi extension
|
||||
if not midi_file.lower().endswith(('.mid', '.midi')):
|
||||
return f"Invalid file type. Expected .mid or .midi file, got: {midi_file}"
|
||||
|
||||
if notes != "all":
|
||||
def VALIDATE_INPUTS(cls, midi, notes, extraction_method, frame_rate, frame_count, width, height, chord_only=False):
|
||||
if notes:
|
||||
try:
|
||||
note_list = [int(n.strip()) for n in notes.split(',') if n.strip()]
|
||||
if not all(0 <= n <= 127 for n in note_list):
|
||||
return "Invalid note value. All notes must be between 0 and 127."
|
||||
except ValueError:
|
||||
return "Invalid notes format. Please provide comma-separated integers or 'all'."
|
||||
return "Invalid notes format. Please provide comma-separated integers."
|
||||
|
||||
return True
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MIDIFeatureExtractor": MIDIFeatureExtractor,
|
||||
}
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
@PromptServer.instance.routes.post('/get_track_notes')
|
||||
async def get_track_notes(request):
|
||||
data = await request.json()
|
||||
midi_file = data.get('midi_file')
|
||||
|
||||
if not midi_file:
|
||||
return web.json_response({"error": "Missing required parameters"}, status=400)
|
||||
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
return web.json_response({"error": "MIDI file not found"}, status=404)
|
||||
|
||||
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
|
||||
return web.json_response(analysis)
|
||||
|
||||
@routes.post('/upload_midi')
|
||||
async def upload_midi(request):
|
||||
data = await request.post()
|
||||
midi_file = data['file']
|
||||
|
||||
if midi_file and midi_file.filename:
|
||||
safe_filename = os.path.basename(midi_file.filename)
|
||||
|
||||
midi_dir = folder_paths.get_folder_paths("midi_files")[0]
|
||||
|
||||
if not os.path.exists(midi_dir):
|
||||
os.makedirs(midi_dir, exist_ok=True)
|
||||
|
||||
midi_path = os.path.join(midi_dir, safe_filename)
|
||||
|
||||
with open(midi_path, 'wb') as f:
|
||||
shutil.copyfileobj(midi_file.file, f)
|
||||
|
||||
midi_files = folder_paths.get_filename_list("midi_files")
|
||||
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
|
||||
|
||||
return web.json_response({
|
||||
"status": "success",
|
||||
"uploaded_file": safe_filename,
|
||||
"midi_files": midi_files,
|
||||
"analysis": analysis
|
||||
})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "No file uploaded"}, status=400)
|
||||
|
||||
@PromptServer.instance.routes.post('/refresh_midi_data')
|
||||
async def refresh_midi_data(request):
|
||||
data = await request.json()
|
||||
midi_file = data.get('midi_file')
|
||||
track_selection = data.get('track_selection')
|
||||
|
||||
if not midi_file:
|
||||
return web.json_response({"error": "Missing required parameters"}, status=400)
|
||||
|
||||
midi_path = folder_paths.get_full_path("midi_files", midi_file)
|
||||
if not midi_path or not os.path.exists(midi_path):
|
||||
return web.json_response({"error": "MIDI file not found"}, status=404)
|
||||
|
||||
analysis = MIDILoadAndExtract.analyze_midi(midi_path)
|
||||
|
||||
# Filter notes based on track selection
|
||||
if track_selection != "all":
|
||||
track_index = track_selection.split(':')[0]
|
||||
analysis['all_notes'] = analysis['track_notes'].get(track_index, "")
|
||||
|
||||
return web.json_response(analysis)
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MIDIFeatureExtractor": "MIDI Feature Extractor",
|
||||
}
|
||||
@@ -779,3 +779,115 @@ class FeaturePeakDetector(FeatureModulationBase):
|
||||
processed_feature = self.create_processed_feature(feature, peak_signal, name_prefix, invert_output)
|
||||
return (processed_feature,)
|
||||
|
||||
@apply_tooltips
|
||||
class FeatureInterpolateMulti(FeatureModulationBase):
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"feature1": ("FEATURE",),
|
||||
"feature2": ("FEATURE",),
|
||||
"feature3": ("FEATURE",),
|
||||
"min_aggregate_value": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
|
||||
"transition_frames": ("INT", {"default": 5, "min": 1, "max": 100, "step": 1}),
|
||||
**super().INPUT_TYPES()["required"],
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FEATURE", "FEATURE", "FEATURE")
|
||||
RETURN_NAMES = ("FEATURE1", "FEATURE2", "FEATURE3")
|
||||
FUNCTION = "multi_interpolate"
|
||||
|
||||
def multi_interpolate(self, feature1, feature2, feature3, min_aggregate_value, transition_frames, invert_output):
|
||||
# Ensure all features have same length
|
||||
frame_count = min(feature1.frame_count, feature2.frame_count, feature3.frame_count)
|
||||
|
||||
# Extract values
|
||||
values1 = np.array([feature1.get_value_at_frame(i) for i in range(frame_count)])
|
||||
values2 = np.array([feature2.get_value_at_frame(i) for i in range(frame_count)])
|
||||
values3 = np.array([feature3.get_value_at_frame(i) for i in range(frame_count)])
|
||||
|
||||
# Stack features
|
||||
stacked = np.stack([values1, values2, values3], axis=1) # [frame, 3]
|
||||
|
||||
# Create output array
|
||||
output = np.zeros_like(stacked)
|
||||
|
||||
# Find transition points (where signals change significantly)
|
||||
threshold = 0.05 # Threshold to detect changes
|
||||
transitions = []
|
||||
|
||||
for i in range(1, frame_count):
|
||||
# Check for significant changes in any feature
|
||||
changes = np.abs(stacked[i] - stacked[i-1])
|
||||
if np.any(changes > threshold):
|
||||
transitions.append(i)
|
||||
|
||||
# Process each segment between transitions
|
||||
segment_start = 0
|
||||
for transition in transitions + [frame_count]:
|
||||
segment_end = transition
|
||||
|
||||
# Look ahead and behind to determine transition window
|
||||
transition_start = max(segment_start - transition_frames // 2, 0)
|
||||
transition_end = min(segment_end + transition_frames // 2, frame_count)
|
||||
|
||||
# For the main part of the segment (no transition), use normalized values
|
||||
if segment_end > segment_start:
|
||||
# Get the average feature values in this segment
|
||||
segment_values = np.mean(stacked[segment_start:segment_end], axis=0)
|
||||
|
||||
# Normalize to ensure sum equals min_aggregate_value
|
||||
sum_segment = np.sum(segment_values)
|
||||
if sum_segment > 0:
|
||||
normalized_segment = (segment_values / sum_segment) * min_aggregate_value
|
||||
else:
|
||||
# If all zero, distribute evenly
|
||||
normalized_segment = np.ones(3) * (min_aggregate_value / 3)
|
||||
|
||||
# Apply to the non-transition part of the segment
|
||||
for i in range(segment_start, segment_end):
|
||||
output[i] = normalized_segment
|
||||
|
||||
segment_start = segment_end
|
||||
|
||||
# Create linear transitions between segments
|
||||
for i in range(len(transitions)):
|
||||
trans_idx = transitions[i]
|
||||
|
||||
# Create transition window
|
||||
window_start = max(trans_idx - transition_frames, 0)
|
||||
window_end = min(trans_idx + transition_frames, frame_count)
|
||||
|
||||
if window_end > window_start:
|
||||
# Get stable values before and after transition
|
||||
before_value = output[max(window_start - 1, 0)]
|
||||
after_value = output[min(window_end, frame_count - 1)]
|
||||
|
||||
# Linear interpolation across the window
|
||||
for j in range(window_start, window_end):
|
||||
alpha = (j - window_start) / (window_end - window_start)
|
||||
interpolated = (1 - alpha) * before_value + alpha * after_value
|
||||
|
||||
# Ensure sum is maintained at min_aggregate_value
|
||||
sum_interp = np.sum(interpolated)
|
||||
if sum_interp > 0:
|
||||
output[j] = (interpolated / sum_interp) * min_aggregate_value
|
||||
else:
|
||||
output[j] = np.ones(3) * (min_aggregate_value / 3)
|
||||
|
||||
# Final check: ensure all frames sum exactly to min_aggregate_value
|
||||
for i in range(frame_count):
|
||||
total = np.sum(output[i])
|
||||
if abs(total - min_aggregate_value) > 1e-6 and total > 0:
|
||||
output[i] = (output[i] / total) * min_aggregate_value
|
||||
elif total == 0:
|
||||
output[i] = np.ones(3) * (min_aggregate_value / 3)
|
||||
|
||||
# Split back out
|
||||
out1 = self.create_processed_feature(feature1, output[:, 0], "InterpolatedMulti", invert_output)
|
||||
out2 = self.create_processed_feature(feature2, output[:, 1], "InterpolatedMulti", invert_output)
|
||||
out3 = self.create_processed_feature(feature3, output[:, 2], "InterpolatedMulti", invert_output)
|
||||
|
||||
return (out1, out2, out3)
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ class EffectVisualizer(RyanOnTheInside):
|
||||
"feature": ("FEATURE",),
|
||||
"text_color": ("STRING", {"default": "(255,255,255)"}),
|
||||
"font_scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}),
|
||||
"label_prefix": ("STRING", {"default": ""}),
|
||||
"label_x_offset": ("INT", {"default": 10, "min": 0, "max": 1000, "step": 1}),
|
||||
"label_y_offset": ("INT", {"default": 10, "min": 0, "max": 1000, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ class EffectVisualizer(RyanOnTheInside):
|
||||
FUNCTION = "visualize"
|
||||
CATEGORY = "RyanOnTheInside/FlexFeatures/Utilities/Previews"
|
||||
|
||||
def visualize(self, video_frames, feature, text_color, font_scale):
|
||||
def visualize(self, video_frames, feature, text_color, font_scale, label_prefix, label_x_offset, label_y_offset):
|
||||
text_color = self.parse_color(text_color)
|
||||
output_frames = []
|
||||
padding = 10 # Padding from the edges
|
||||
@@ -39,10 +42,11 @@ class EffectVisualizer(RyanOnTheInside):
|
||||
|
||||
# Display feature name and value on the frame
|
||||
feature_value = feature.get_value_at_frame(frame_index)
|
||||
text = f"{feature.name}: {feature_value:.2f}"
|
||||
prefix = f"{label_prefix} " if label_prefix else ""
|
||||
text = f"{prefix}{feature.name}: {feature_value:.2f}"
|
||||
text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 2)[0]
|
||||
text_x = padding
|
||||
text_y = padding + text_size[1]
|
||||
text_x = label_x_offset
|
||||
text_y = label_y_offset + text_size[1]
|
||||
|
||||
cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_color, 2)
|
||||
|
||||
@@ -74,7 +78,8 @@ class ProximityVisualizer(EffectVisualizer):
|
||||
FUNCTION = "visualize_proximity"
|
||||
|
||||
def visualize_proximity(self, video_frames, anchor_locations, query_locations, feature,
|
||||
anchor_color, query_color, line_color, text_color, font_scale):
|
||||
anchor_color, query_color, line_color, text_color, font_scale,
|
||||
label_prefix, label_x_offset, label_y_offset):
|
||||
anchor_color = self.parse_color(anchor_color)
|
||||
query_color = self.parse_color(query_color)
|
||||
line_color = self.parse_color(line_color)
|
||||
@@ -152,13 +157,16 @@ class PitchVisualizer(EffectVisualizer):
|
||||
"feature": ("FEATURE",),
|
||||
"text_color": ("STRING", {"default": "(255,255,255)"}),
|
||||
"font_scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 2.0, "step": 0.1}),
|
||||
"label_prefix": ("STRING", {"default": ""}),
|
||||
"label_x_offset": ("INT", {"default": 10, "min": 0, "max": 1000, "step": 1}),
|
||||
"label_y_offset": ("INT", {"default": 10, "min": 0, "max": 1000, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "visualize_pitch"
|
||||
|
||||
def visualize_pitch(self, video_frames, feature, text_color, font_scale):
|
||||
def visualize_pitch(self, video_frames, feature, text_color, font_scale, label_prefix, label_x_offset, label_y_offset):
|
||||
text_color = self.parse_color(text_color)
|
||||
output_frames = []
|
||||
padding = 10 # Padding from the edges
|
||||
@@ -191,8 +199,8 @@ class PitchVisualizer(EffectVisualizer):
|
||||
|
||||
for i, text in enumerate(texts):
|
||||
text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, 2)[0]
|
||||
text_x = padding
|
||||
text_y = padding + (i + 1) * (text_size[1] + 10) # Add some vertical spacing
|
||||
text_x = label_x_offset
|
||||
text_y = label_y_offset + (i + 1) * (text_size[1] + 10) # Add some vertical spacing
|
||||
|
||||
cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_color, 2)
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ Great for creating music videos, reactive animations, or automated effects.""")
|
||||
}, inherits_from='ProximityFeatureInput')
|
||||
|
||||
# MIDILoadAndExtract tooltips (inherits from: FeatureExtractorBase)
|
||||
TooltipManager.register_tooltips("MIDILoadAndExtract", {
|
||||
TooltipManager.register_tooltips("MIDIFeatureExtractor", {
|
||||
"midi_file": "MIDI file to load from the midi_files directory",
|
||||
"extraction_method": """Choose what MIDI information to analyze:
|
||||
|
||||
@@ -248,7 +248,7 @@ Great for creating music videos, reactive animations, or automated effects.""")
|
||||
"track_selection": "Track to analyze ('all' or specific track number)",
|
||||
"chord_only": "When true, only considers full chords (default: false)",
|
||||
"notes": "Comma-separated list of MIDI note numbers (default: empty)"
|
||||
}, inherits_from='FeatureExtractorBase')
|
||||
}, inherits_from='FeatureExtractorBase', description="Extract features from MIDI files. Analyze note on/off events, velocity, pitch, and more to create dynamic animations and effects. Use the piano to filter the notes that are considered for extraction, or leave none selected to use them all. Notes that do not exist in the MIDI file will be disabled.")
|
||||
|
||||
# AudioFeatureExtractor tooltips (inherits from: FeatureExtractorBase)
|
||||
TooltipManager.register_tooltips("AudioFeatureExtractor", {
|
||||
|
||||
+189
-241
@@ -2,14 +2,11 @@ import { app } from "../../scripts/app.js";
|
||||
import { drawPiano, handlePianoMouseDown } from "./piano.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "org.ryanontheinside.midi_load_and_extract",
|
||||
name: "org.ryanontheinside.midi_feature_extractor",
|
||||
|
||||
// async setup() {
|
||||
// console.log("MIDI Extension: Setup called");
|
||||
// },
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "MIDILoadAndExtract") {
|
||||
//claude: do not change the node name
|
||||
if (nodeData.name === "MIDIFeatureExtractor") {
|
||||
|
||||
// Add the disableWidget method to the nodeType prototype
|
||||
nodeType.prototype.disableWidget = function(widget) {
|
||||
@@ -21,47 +18,6 @@ app.registerExtension({
|
||||
widget.onMouseUp = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.uploadMIDI = function() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".mid,.midi";
|
||||
input.onchange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
fetch("/upload_midi", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Update the midi_file widget with the new file path
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget) {
|
||||
midiFileWidget.value = data.file_path;
|
||||
// Trigger any necessary updates
|
||||
if (midiFileWidget.callback) {
|
||||
midiFileWidget.callback(data.file_path);
|
||||
}
|
||||
}
|
||||
// You might want to call updateTrackSelection here if it's needed
|
||||
this.updateTrackSelection();
|
||||
this.saveState(); // Save state after successful upload
|
||||
} else {
|
||||
console.error("Failed to upload MIDI file:", data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error uploading MIDI file:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
this.properties = this.properties || {};
|
||||
@@ -71,14 +27,6 @@ app.registerExtension({
|
||||
this.scrollRightIndicator = null;
|
||||
this.availableNotes = new Set();
|
||||
|
||||
this.addWidget("button", "Upload MIDI", "upload", () => {
|
||||
this.uploadMIDI();
|
||||
});
|
||||
|
||||
this.addWidget("button", "Refresh", "refresh", () => {
|
||||
this.refreshMIDIData();
|
||||
});
|
||||
|
||||
const notesWidget = this.widgets.find(widget => widget.name === "notes");
|
||||
if (notesWidget) {
|
||||
// Disable the widget
|
||||
@@ -87,20 +35,6 @@ app.registerExtension({
|
||||
notesWidget.hidden = true;
|
||||
}
|
||||
|
||||
// Add event listeners for midi_file and track_selection widgets
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget) {
|
||||
midiFileWidget.callback = (value) => {
|
||||
this.updateTrackSelection();
|
||||
this.saveState(); // Save state when MIDI file changes
|
||||
};
|
||||
}
|
||||
|
||||
const trackSelectionWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackSelectionWidget) {
|
||||
trackSelectionWidget.callback = () => this.onTrackSelectionChange(trackSelectionWidget.value);
|
||||
}
|
||||
|
||||
// Increase the node's height and width
|
||||
const extraHeight = 80; // Piano height + margins
|
||||
const pianoWidth = 1020; // Total width of the piano
|
||||
@@ -116,156 +50,225 @@ app.registerExtension({
|
||||
// Load saved state
|
||||
this.loadSavedState();
|
||||
|
||||
// Set up a flag to indicate initial load
|
||||
this.isInitialLoad = true;
|
||||
|
||||
// Instead of calling updateTrackSelection here, we'll set up a callback
|
||||
// that will be called when the graph is available
|
||||
this.onGraphAvailable = () => {
|
||||
this.updateTrackSelection();
|
||||
};
|
||||
// Check for existing connections after a short delay
|
||||
setTimeout(() => {
|
||||
const connectedNodes = this.graph._nodes.filter(
|
||||
n => n.type === 'MIDILoader' && this.isNodeConnected(n)
|
||||
);
|
||||
|
||||
if (connectedNodes.length > 0) {
|
||||
const loaderNode = connectedNodes[0];
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
this.listenToMIDILoaderChanges(loaderNode);
|
||||
}
|
||||
}, 100); // Small delay to ensure links are established
|
||||
};
|
||||
|
||||
nodeType.prototype.loadSavedState = function() {
|
||||
const savedState = localStorage.getItem(`MIDILoadAndExtract_${this.id}`);
|
||||
if (savedState) {
|
||||
this.loadedState = JSON.parse(savedState);
|
||||
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget && this.loadedState.midiFile) {
|
||||
midiFileWidget.value = this.loadedState.midiFile;
|
||||
// Handle connections changing
|
||||
nodeType.prototype.onConnectionsChange = function(type, index, connected, link_info) {
|
||||
if (type === 1) { // Input connection (1 is for inputs, 0 is for outputs)
|
||||
if (connected && link_info) {
|
||||
// New connection established
|
||||
const inputNode = this.graph.getNodeById(link_info.origin_id);
|
||||
if (inputNode && inputNode.type === 'MIDILoader') {
|
||||
this.queryMIDILoaderNotes(inputNode);
|
||||
this.listenToMIDILoaderChanges(inputNode);
|
||||
}
|
||||
} else {
|
||||
// Check if we still have any connected MIDILoader nodes
|
||||
const connectedMidiNodes = this.graph._nodes.filter(
|
||||
n => n.type === 'MIDILoader' && this.isNodeConnected(n)
|
||||
);
|
||||
|
||||
if (connectedMidiNodes.length === 0) {
|
||||
// All MIDILoader connections removed
|
||||
this.availableNotes = new Set();
|
||||
this.properties.selectedNotes = [];
|
||||
this.syncNotesWidget();
|
||||
this.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.updateTrackSelection = async function() {
|
||||
if (this.isUpdatingTrackSelection) {
|
||||
console.log('Track selection update already in progress, skipping');
|
||||
return;
|
||||
// Check if a node is connected to this node
|
||||
nodeType.prototype.isNodeConnected = function(node) {
|
||||
for (const link_id in this.graph.links) {
|
||||
const link = this.graph.links[link_id];
|
||||
if (link.target_id === this.id && link.origin_id === node.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this.isUpdatingTrackSelection = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Query the MIDILoader for available notes
|
||||
nodeType.prototype.queryMIDILoaderNotes = async function(loaderNode) {
|
||||
if (!loaderNode) return;
|
||||
|
||||
const midiFile = loaderNode.getCurrentMidiFile();
|
||||
const trackSelection = loaderNode.widgets.find(w => w.name === "track_selection").value;
|
||||
|
||||
// Get measure selection parameters from the MIDILoader
|
||||
const startMeasureWidget = loaderNode.widgets.find(w => w.name === "start_measure");
|
||||
const startBeatWidget = loaderNode.widgets.find(w => w.name === "start_beat");
|
||||
const endMeasureWidget = loaderNode.widgets.find(w => w.name === "end_measure");
|
||||
const endBeatWidget = loaderNode.widgets.find(w => w.name === "end_beat");
|
||||
|
||||
const startMeasure = startMeasureWidget ? startMeasureWidget.value : 1;
|
||||
const startBeat = startBeatWidget ? startBeatWidget.value : 1;
|
||||
const endMeasure = endMeasureWidget ? endMeasureWidget.value : 0;
|
||||
const endBeat = endBeatWidget ? endBeatWidget.value : 1;
|
||||
|
||||
if (!midiFile) return;
|
||||
|
||||
try {
|
||||
const midiFile = this.getCurrentMidiFile();
|
||||
|
||||
if (!midiFile) {
|
||||
console.warn('No MIDI file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/get_track_notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ midi_file: midiFile })
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(`Querying MIDI notes with startMeasure=${startMeasure}, startBeat=${startBeat}, endMeasure=${endMeasure}, endBeat=${endBeat}`);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error fetching track notes:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const trackSelectionWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackSelectionWidget) {
|
||||
// Update the options
|
||||
trackSelectionWidget.options.values = data.tracks;
|
||||
}
|
||||
|
||||
// Update available notes
|
||||
this.availableNotes = new Set(data.all_notes.split(',').map(Number));
|
||||
|
||||
// Apply loaded state if this is the initial load
|
||||
if (this.isInitialLoad && this.loadedState) {
|
||||
this.applyLoadedState();
|
||||
this.isInitialLoad = false;
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (error) {
|
||||
console.error('Error updating track selection:', error);
|
||||
this.availableNotes = new Set(); // Reset to empty Set in case of error
|
||||
} finally {
|
||||
this.isUpdatingTrackSelection = false;
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.refreshMIDIData = async function() {
|
||||
const midiFile = this.getCurrentMidiFile();
|
||||
const trackSelection = this.widgets.find(w => w.name === "track_selection").value;
|
||||
|
||||
if (!midiFile) {
|
||||
console.warn('No MIDI file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/refresh_midi_data', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ midi_file: midiFile, track_selection: trackSelection })
|
||||
body: JSON.stringify({
|
||||
midi_file: midiFile,
|
||||
track_selection: trackSelection,
|
||||
start_measure: startMeasure,
|
||||
start_beat: startBeat,
|
||||
end_measure: endMeasure,
|
||||
end_beat: endBeat
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error refreshing MIDI data:', data.error);
|
||||
console.error('Error fetching MIDI notes:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Update available tracks
|
||||
const trackSelectionWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackSelectionWidget) {
|
||||
trackSelectionWidget.options.values = data.tracks;
|
||||
}
|
||||
|
||||
// Update available notes
|
||||
this.availableNotes = new Set(data.all_notes.split(',').map(Number));
|
||||
const noteArray = data.all_notes.split(',').map(Number).filter(n => !isNaN(n));
|
||||
console.log(`Received ${noteArray.length} available notes from measures ${startMeasure} to ${endMeasure}, time signature: ${data.time_signature}`);
|
||||
|
||||
this.availableNotes = new Set(noteArray);
|
||||
|
||||
// Filter out selected notes that are no longer available
|
||||
if (this.properties.selectedNotes) {
|
||||
this.properties.selectedNotes = this.properties.selectedNotes.filter(note => this.availableNotes.has(note));
|
||||
} else {
|
||||
this.properties.selectedNotes = [];
|
||||
}
|
||||
this.syncNotesWidget();
|
||||
|
||||
// Trigger redraw
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing MIDI data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.applyLoadedState = function() {
|
||||
if (this.loadedState) {
|
||||
|
||||
const trackSelectionWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackSelectionWidget && this.loadedState.trackSelection) {
|
||||
if (trackSelectionWidget.options.values.includes(this.loadedState.trackSelection)) {
|
||||
trackSelectionWidget.value = this.loadedState.trackSelection;
|
||||
} else {
|
||||
console.log(`Loaded track selection ${this.loadedState.trackSelection} is no longer valid`);
|
||||
const originalCount = this.properties.selectedNotes.length;
|
||||
this.properties.selectedNotes = this.properties.selectedNotes.filter(
|
||||
note => this.availableNotes.has(note)
|
||||
);
|
||||
|
||||
if (originalCount !== this.properties.selectedNotes.length) {
|
||||
console.log(`Filtered out ${originalCount - this.properties.selectedNotes.length} selected notes`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.loadedState.selectedNotes) {
|
||||
this.properties.selectedNotes = this.loadedState.selectedNotes.filter(note => this.availableNotes.has(note));
|
||||
}
|
||||
|
||||
this.syncNotesWidget();
|
||||
delete this.loadedState;
|
||||
this.saveState(); // Save the applied state
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (error) {
|
||||
console.error('Error querying MIDI loader:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for changes in the MIDILoader node
|
||||
nodeType.prototype.listenToMIDILoaderChanges = function(loaderNode) {
|
||||
// Store reference to loader node
|
||||
this.connectedLoaderNode = loaderNode;
|
||||
|
||||
// Check if we already added a listener to this node
|
||||
if (loaderNode._feature_extractor_listeners &&
|
||||
loaderNode._feature_extractor_listeners.includes(this.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize listener array if needed
|
||||
if (!loaderNode._feature_extractor_listeners) {
|
||||
loaderNode._feature_extractor_listeners = [];
|
||||
}
|
||||
loaderNode._feature_extractor_listeners.push(this.id);
|
||||
|
||||
// Find all relevant widgets in loader node
|
||||
const trackWidget = loaderNode.widgets.find(w => w.name === "track_selection");
|
||||
const startMeasureWidget = loaderNode.widgets.find(w => w.name === "start_measure");
|
||||
const startBeatWidget = loaderNode.widgets.find(w => w.name === "start_beat");
|
||||
const endMeasureWidget = loaderNode.widgets.find(w => w.name === "end_measure");
|
||||
const endBeatWidget = loaderNode.widgets.find(w => w.name === "end_beat");
|
||||
|
||||
// Set up callbacks for all relevant widgets
|
||||
if (trackWidget) {
|
||||
const origTrackCallback = trackWidget.callback;
|
||||
trackWidget.callback = (value) => {
|
||||
// Call original callback
|
||||
if (origTrackCallback) origTrackCallback.call(loaderNode, value);
|
||||
|
||||
// Notify this feature extractor
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
};
|
||||
}
|
||||
|
||||
if (startMeasureWidget) {
|
||||
const origCallback = startMeasureWidget.callback;
|
||||
startMeasureWidget.callback = (value) => {
|
||||
// Call original callback
|
||||
if (origCallback) origCallback.call(loaderNode, value);
|
||||
|
||||
// Notify this feature extractor
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
};
|
||||
}
|
||||
|
||||
if (startBeatWidget) {
|
||||
const origCallback = startBeatWidget.callback;
|
||||
startBeatWidget.callback = (value) => {
|
||||
// Call original callback
|
||||
if (origCallback) origCallback.call(loaderNode, value);
|
||||
|
||||
// Notify this feature extractor
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
};
|
||||
}
|
||||
|
||||
if (endMeasureWidget) {
|
||||
const origCallback = endMeasureWidget.callback;
|
||||
endMeasureWidget.callback = (value) => {
|
||||
// Call original callback
|
||||
if (origCallback) origCallback.call(loaderNode, value);
|
||||
|
||||
// Notify this feature extractor
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
};
|
||||
}
|
||||
|
||||
if (endBeatWidget) {
|
||||
const origCallback = endBeatWidget.callback;
|
||||
endBeatWidget.callback = (value) => {
|
||||
// Call original callback
|
||||
if (origCallback) origCallback.call(loaderNode, value);
|
||||
|
||||
// Notify this feature extractor
|
||||
this.queryMIDILoaderNotes(loaderNode);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.loadSavedState = function() {
|
||||
const savedState = localStorage.getItem(`MIDIFeatureExtractor_${this.id}`);
|
||||
if (savedState) {
|
||||
try {
|
||||
const state = JSON.parse(savedState);
|
||||
if (state.selectedNotes) {
|
||||
this.properties.selectedNotes = state.selectedNotes;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading saved state:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.saveState = function() {
|
||||
const state = {
|
||||
midiFile: this.getCurrentMidiFile(),
|
||||
trackSelection: this.widgets.find(w => w.name === "track_selection")?.value,
|
||||
selectedNotes: this.properties.selectedNotes
|
||||
};
|
||||
localStorage.setItem(`MIDILoadAndExtract_${this.id}`, JSON.stringify(state));
|
||||
localStorage.setItem(`MIDIFeatureExtractor_${this.id}`, JSON.stringify(state));
|
||||
};
|
||||
|
||||
nodeType.prototype.syncNotesWidget = function() {
|
||||
@@ -283,10 +286,8 @@ app.registerExtension({
|
||||
// Ensure the node updates its internal state
|
||||
this.onPropertyChanged('selectedNotes');
|
||||
|
||||
// Only call saveState if it's available
|
||||
if (typeof this.saveState === 'function') {
|
||||
this.saveState();
|
||||
}
|
||||
// Save state
|
||||
this.saveState();
|
||||
};
|
||||
|
||||
nodeType.prototype.onPropertyChanged = function(property) {
|
||||
@@ -309,7 +310,6 @@ app.registerExtension({
|
||||
|
||||
// Ensure availableNotes exists before drawing
|
||||
if (!this.availableNotes) {
|
||||
console.warn('availableNotes is undefined, initializing as empty Set');
|
||||
this.availableNotes = new Set();
|
||||
}
|
||||
|
||||
@@ -338,58 +338,6 @@ app.registerExtension({
|
||||
const [x, y] = pos;
|
||||
return handlePianoMouseDown(this, x, y);
|
||||
};
|
||||
|
||||
nodeType.prototype.getCurrentMidiFile = function() {
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
return midiFileWidget ? midiFileWidget.value : null;
|
||||
};
|
||||
|
||||
nodeType.prototype.onTrackSelectionChange = async function(selectedTrack) {
|
||||
try {
|
||||
const midiFile = this.getCurrentMidiFile();
|
||||
const response = await fetch('/get_track_notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ midi_file: midiFile })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error fetching track notes:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const notesInTrack = selectedTrack === "all"
|
||||
? data.all_notes
|
||||
: data.track_notes[selectedTrack.split(':')[0]];
|
||||
|
||||
this.availableNotes = new Set(notesInTrack.split(',').map(Number));
|
||||
|
||||
// Filter out selected notes that are no longer available
|
||||
if (this.properties.selectedNotes) {
|
||||
this.properties.selectedNotes = this.properties.selectedNotes.filter(note => this.availableNotes.has(note));
|
||||
} else {
|
||||
this.properties.selectedNotes = [];
|
||||
}
|
||||
this.syncNotesWidget();
|
||||
|
||||
// Trigger redraw
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (error) {
|
||||
console.error('Error updating piano notes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add this at the end of the function
|
||||
const onAdded = app.graph.onNodeAdded;
|
||||
app.graph.onNodeAdded = function(node) {
|
||||
if (onAdded) {
|
||||
onAdded.call(this, node);
|
||||
}
|
||||
if (node.type === "MIDILoadAndExtract" && node.onGraphLoaded) {
|
||||
node.onGraphLoaded();
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { drawPiano, handlePianoMouseDown } from "./piano.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "org.ryanontheinside.midi_loader",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "MIDILoader") {
|
||||
|
||||
nodeType.prototype.disableWidget = function(widget) {
|
||||
if (widget) {
|
||||
widget.disabled = true;
|
||||
widget.visibleWidth = 0;
|
||||
widget.onMouseDown = () => {};
|
||||
widget.onMouseMove = () => {};
|
||||
widget.onMouseUp = () => {};
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.uploadMIDI = function() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".mid,.midi";
|
||||
input.onchange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
fetch("/upload_midi", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === "success") {
|
||||
// Update the midi_file widget with the new file
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget) {
|
||||
midiFileWidget.value = data.uploaded_file;
|
||||
// Trigger any necessary updates
|
||||
if (midiFileWidget.callback) {
|
||||
midiFileWidget.callback(data.uploaded_file);
|
||||
}
|
||||
}
|
||||
this.updateTrackSelection();
|
||||
this.saveState();
|
||||
} else {
|
||||
console.error("Failed to upload MIDI file:", data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error uploading MIDI file:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const originalNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
this.properties = this.properties || {};
|
||||
this.pianoScroll = 0;
|
||||
this.scrollLeftIndicator = null;
|
||||
this.scrollRightIndicator = null;
|
||||
this.availableNotes = new Set();
|
||||
|
||||
this.addWidget("button", "Upload MIDI", "upload", () => {
|
||||
this.uploadMIDI();
|
||||
});
|
||||
|
||||
this.addWidget("button", "Refresh", "refresh", () => {
|
||||
this.refreshMIDIData();
|
||||
});
|
||||
|
||||
// Add event listeners for midi_file widget
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget) {
|
||||
midiFileWidget.callback = (value) => {
|
||||
this.updateTrackSelection();
|
||||
this.saveState(); // Save state when MIDI file changes
|
||||
};
|
||||
}
|
||||
|
||||
const trackSelectionWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackSelectionWidget) {
|
||||
const origTrackCallback = trackSelectionWidget.callback;
|
||||
trackSelectionWidget.callback = (value) => {
|
||||
// Call original callback if it exists
|
||||
if (origTrackCallback) origTrackCallback.call(this, value);
|
||||
this.saveState();
|
||||
|
||||
// Notify connected nodes of the change
|
||||
this.notifyConnectedNodes();
|
||||
};
|
||||
}
|
||||
|
||||
// Set up callbacks for start time and duration widgets
|
||||
const startTimeWidget = this.widgets.find(w => w.name === "start_time_seconds");
|
||||
if (startTimeWidget) {
|
||||
const origStartCallback = startTimeWidget.callback;
|
||||
startTimeWidget.callback = (value) => {
|
||||
// Call original callback if it exists
|
||||
if (origStartCallback) origStartCallback.call(this, value);
|
||||
this.saveState();
|
||||
|
||||
// Notify connected nodes of the change
|
||||
this.notifyConnectedNodes();
|
||||
};
|
||||
}
|
||||
|
||||
const durationWidget = this.widgets.find(w => w.name === "duration_seconds");
|
||||
if (durationWidget) {
|
||||
const origDurCallback = durationWidget.callback;
|
||||
durationWidget.callback = (value) => {
|
||||
// Call original callback if it exists
|
||||
if (origDurCallback) origDurCallback.call(this, value);
|
||||
this.saveState();
|
||||
|
||||
// Notify connected nodes of the change
|
||||
this.notifyConnectedNodes();
|
||||
};
|
||||
}
|
||||
|
||||
// Increase the node's height and width
|
||||
const extraHeight = 80; // Piano height + margins
|
||||
const pianoWidth = 380; // Reduced width for just the loader
|
||||
this.size[1] += extraHeight;
|
||||
this.size[0] = Math.max(this.size[0], pianoWidth);
|
||||
|
||||
// Ensure the node recomputes its size
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
// Load saved state
|
||||
this.loadSavedState();
|
||||
|
||||
// Set up a flag to indicate initial load
|
||||
this.isInitialLoad = true;
|
||||
|
||||
// Set up a callback that will be called when the graph is available
|
||||
this.onGraphAvailable = () => {
|
||||
this.updateTrackSelection();
|
||||
};
|
||||
|
||||
// Initialize the feature extractor listeners array
|
||||
this._feature_extractor_listeners = [];
|
||||
|
||||
// Same for refreshMIDIData method
|
||||
const originalRefreshMIDIData = this.refreshMIDIData;
|
||||
this.refreshMIDIData = async function() {
|
||||
await originalRefreshMIDIData.call(this);
|
||||
this.notifyConnectedNodes();
|
||||
};
|
||||
};
|
||||
|
||||
nodeType.prototype.loadSavedState = function() {
|
||||
const savedState = localStorage.getItem(`MIDILoader_${this.id}`);
|
||||
if (savedState) {
|
||||
this.loadedState = JSON.parse(savedState);
|
||||
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
if (midiFileWidget && this.loadedState.midiFile) {
|
||||
midiFileWidget.value = this.loadedState.midiFile;
|
||||
}
|
||||
|
||||
if (this.loadedState.trackSelection) {
|
||||
const trackWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackWidget) {
|
||||
trackWidget.value = this.loadedState.trackSelection;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore measure/beat selection
|
||||
if (this.loadedState.startMeasure !== undefined) {
|
||||
const widget = this.widgets.find(w => w.name === "start_measure");
|
||||
if (widget) widget.value = this.loadedState.startMeasure;
|
||||
}
|
||||
|
||||
if (this.loadedState.startBeat !== undefined) {
|
||||
const widget = this.widgets.find(w => w.name === "start_beat");
|
||||
if (widget) widget.value = this.loadedState.startBeat;
|
||||
}
|
||||
|
||||
if (this.loadedState.endMeasure !== undefined) {
|
||||
const widget = this.widgets.find(w => w.name === "end_measure");
|
||||
if (widget) widget.value = this.loadedState.endMeasure;
|
||||
}
|
||||
|
||||
if (this.loadedState.endBeat !== undefined) {
|
||||
const widget = this.widgets.find(w => w.name === "end_beat");
|
||||
if (widget) widget.value = this.loadedState.endBeat;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.updateTrackSelection = async function() {
|
||||
if (this.isUpdatingTrackSelection) {
|
||||
console.log('Track selection update already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
this.isUpdatingTrackSelection = true;
|
||||
|
||||
try {
|
||||
const midiFile = this.getCurrentMidiFile();
|
||||
|
||||
if (!midiFile) {
|
||||
console.warn('No MIDI file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get measure selection parameters
|
||||
const startMeasureWidget = this.widgets.find(w => w.name === "start_measure");
|
||||
const startBeatWidget = this.widgets.find(w => w.name === "start_beat");
|
||||
const endMeasureWidget = this.widgets.find(w => w.name === "end_measure");
|
||||
const endBeatWidget = this.widgets.find(w => w.name === "end_beat");
|
||||
|
||||
const startMeasure = startMeasureWidget ? startMeasureWidget.value : 1;
|
||||
const startBeat = startBeatWidget ? startBeatWidget.value : 1;
|
||||
const endMeasure = endMeasureWidget ? endMeasureWidget.value : 0;
|
||||
const endBeat = endBeatWidget ? endBeatWidget.value : 1;
|
||||
|
||||
const response = await fetch('/get_track_notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
midi_file: midiFile,
|
||||
start_measure: startMeasure,
|
||||
start_beat: startBeat,
|
||||
end_measure: endMeasure,
|
||||
end_beat: endBeat
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error fetching track notes:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackWidget) {
|
||||
// Update the options
|
||||
trackWidget.options.values = data.tracks;
|
||||
}
|
||||
|
||||
// Apply loaded state if this is the initial load
|
||||
if (this.isInitialLoad && this.loadedState) {
|
||||
if (this.loadedState.trackSelection) {
|
||||
const trackWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackWidget && trackWidget.options.values.includes(this.loadedState.trackSelection)) {
|
||||
trackWidget.value = this.loadedState.trackSelection;
|
||||
}
|
||||
}
|
||||
|
||||
this.isInitialLoad = false;
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
|
||||
// Notify connected nodes after track selection update
|
||||
this.notifyConnectedNodes();
|
||||
} catch (error) {
|
||||
console.error('Error updating track selection:', error);
|
||||
} finally {
|
||||
this.isUpdatingTrackSelection = false;
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.refreshMIDIData = async function() {
|
||||
const midiFile = this.getCurrentMidiFile();
|
||||
const trackWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
const trackSelection = trackWidget ? trackWidget.value : "all";
|
||||
|
||||
// Get measure selection parameters
|
||||
const startMeasureWidget = this.widgets.find(w => w.name === "start_measure");
|
||||
const startBeatWidget = this.widgets.find(w => w.name === "start_beat");
|
||||
const endMeasureWidget = this.widgets.find(w => w.name === "end_measure");
|
||||
const endBeatWidget = this.widgets.find(w => w.name === "end_beat");
|
||||
|
||||
const startMeasure = startMeasureWidget ? startMeasureWidget.value : 1;
|
||||
const startBeat = startBeatWidget ? startBeatWidget.value : 1;
|
||||
const endMeasure = endMeasureWidget ? endMeasureWidget.value : 0;
|
||||
const endBeat = endBeatWidget ? endBeatWidget.value : 1;
|
||||
|
||||
if (!midiFile) {
|
||||
console.warn('No MIDI file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/refresh_midi_data', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
midi_file: midiFile,
|
||||
track_selection: trackSelection,
|
||||
start_measure: startMeasure,
|
||||
start_beat: startBeat,
|
||||
end_measure: endMeasure,
|
||||
end_beat: endBeat
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error('Error refreshing MIDI data:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update available tracks
|
||||
const trackWidget = this.widgets.find(w => w.name === "track_selection");
|
||||
if (trackWidget) {
|
||||
trackWidget.options.values = data.tracks;
|
||||
}
|
||||
|
||||
// Trigger redraw
|
||||
this.setDirtyCanvas(true, true);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing MIDI data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.saveState = function() {
|
||||
// Get current widgets' values
|
||||
const startMeasureWidget = this.widgets.find(w => w.name === "start_measure");
|
||||
const startBeatWidget = this.widgets.find(w => w.name === "start_beat");
|
||||
const endMeasureWidget = this.widgets.find(w => w.name === "end_measure");
|
||||
const endBeatWidget = this.widgets.find(w => w.name === "end_beat");
|
||||
|
||||
const state = {
|
||||
midiFile: this.getCurrentMidiFile(),
|
||||
trackSelection: this.widgets.find(w => w.name === "track_selection")?.value,
|
||||
startMeasure: startMeasureWidget ? startMeasureWidget.value : 1,
|
||||
startBeat: startBeatWidget ? startBeatWidget.value : 1,
|
||||
endMeasure: endMeasureWidget ? endMeasureWidget.value : 0,
|
||||
endBeat: endBeatWidget ? endBeatWidget.value : 1
|
||||
};
|
||||
localStorage.setItem(`MIDILoader_${this.id}`, JSON.stringify(state));
|
||||
};
|
||||
|
||||
nodeType.prototype.getCurrentMidiFile = function() {
|
||||
const midiFileWidget = this.widgets.find(w => w.name === "midi_file");
|
||||
return midiFileWidget ? midiFileWidget.value : null;
|
||||
};
|
||||
|
||||
// Add a method to notify connected feature extractors
|
||||
nodeType.prototype.notifyConnectedNodes = function() {
|
||||
if (!this._feature_extractor_listeners) return;
|
||||
|
||||
for (const id of this._feature_extractor_listeners) {
|
||||
const node = this.graph.getNodeById(id);
|
||||
if (node && node.queryMIDILoaderNotes) {
|
||||
node.queryMIDILoaderNotes(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle disconnection to clean up listeners
|
||||
nodeType.prototype.onConnectionsChange = function(type, index, connected, link_info) {
|
||||
if (type === 0 && !connected) { // Output disconnected
|
||||
// Clean up the disconnected listener
|
||||
if (link_info && this._feature_extractor_listeners) {
|
||||
const targetNode = this.graph.getNodeById(link_info.target_id);
|
||||
if (targetNode) {
|
||||
this._feature_extractor_listeners = this._feature_extractor_listeners.filter(
|
||||
id => id !== targetNode.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add this at the end of the function
|
||||
const onAdded = app.graph.onNodeAdded;
|
||||
app.graph.onNodeAdded = function(node) {
|
||||
if (onAdded) {
|
||||
onAdded.call(this, node);
|
||||
}
|
||||
if (node.type === "MIDILoader" && node.onGraphLoaded) {
|
||||
node.onGraphLoaded();
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user