Merge pull request #57 from ryanontheinside/feat/midi-improvements

Feat/midi improvements
This commit is contained in:
RyanOnTheInside
2025-05-24 10:11:48 -04:00
committed by GitHub
10 changed files with 4906 additions and 375 deletions
+11 -4
View File
@@ -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
View File
@@ -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"
}
+61 -119
View File
@@ -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",
}
+112
View File
@@ -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)
+16 -8
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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();
}
};
}
},
});
+385
View File
@@ -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();
}
};
}
},
});