From 6df479d32030a22801101c265fd9d472519e9154 Mon Sep 17 00:00:00 2001 From: melancholytron Date: Wed, 11 Feb 2026 15:59:42 -0600 Subject: [PATCH] Initial commit: MIDI Tools web application Tesla Coil MIDI Processing Suite with FastAPI backend and browser UI. Includes tools for bake tempo, monofy, redundancy check, velocity fix, and type 0 conversion. Features piano roll visualization, track editing, merge/delete, MIDI playback, and session-based undo. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 11 + midi-tools/README.md | 206 +++++++ midi-tools/back-tempo.py | 171 ++++++ midi-tools/baketempo.py | 171 ++++++ midi-tools/midicheck.py | 306 ++++++++++ midi-tools/midicheckjson.py | 283 +++++++++ midi-tools/midicheckjson_single.py | 275 +++++++++ midi-tools/monofy.py | 178 ++++++ midi-tools/reduncheck.py | 122 ++++ midi-tools/type_0.py | 61 ++ midi-tools/velfix.py | 154 +++++ server/.dockerignore | 5 + server/Dockerfile | 12 + server/app/__init__.py | 0 server/app/core/__init__.py | 0 server/app/core/analyze.py | 122 ++++ server/app/core/baketempo.py | 102 ++++ server/app/core/file_handling.py | 13 + server/app/core/midi_utils.py | 72 +++ server/app/core/monofy.py | 122 ++++ server/app/core/reduncheck.py | 40 ++ server/app/core/track_detail.py | 79 +++ server/app/core/type0.py | 12 + server/app/core/velfix.py | 30 + server/app/main.py | 17 + server/app/routers/__init__.py | 0 server/app/routers/session.py | 432 ++++++++++++++ server/app/static/app.js | 897 +++++++++++++++++++++++++++++ server/app/static/index.html | 120 ++++ server/app/static/style.css | 625 ++++++++++++++++++++ server/docker-compose.yml | 6 + server/requirements.txt | 4 + 32 files changed, 4648 insertions(+) create mode 100644 .gitignore create mode 100644 midi-tools/README.md create mode 100644 midi-tools/back-tempo.py create mode 100644 midi-tools/baketempo.py create mode 100644 midi-tools/midicheck.py create mode 100644 midi-tools/midicheckjson.py create mode 100644 midi-tools/midicheckjson_single.py create mode 100644 midi-tools/monofy.py create mode 100644 midi-tools/reduncheck.py create mode 100644 midi-tools/type_0.py create mode 100644 midi-tools/velfix.py create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/app/__init__.py create mode 100644 server/app/core/__init__.py create mode 100644 server/app/core/analyze.py create mode 100644 server/app/core/baketempo.py create mode 100644 server/app/core/file_handling.py create mode 100644 server/app/core/midi_utils.py create mode 100644 server/app/core/monofy.py create mode 100644 server/app/core/reduncheck.py create mode 100644 server/app/core/track_detail.py create mode 100644 server/app/core/type0.py create mode 100644 server/app/core/velfix.py create mode 100644 server/app/main.py create mode 100644 server/app/routers/__init__.py create mode 100644 server/app/routers/session.py create mode 100644 server/app/static/app.js create mode 100644 server/app/static/index.html create mode 100644 server/app/static/style.css create mode 100644 server/docker-compose.yml create mode 100644 server/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1859172 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.claude/ +__pycache__/ +*.pyc +*.pyo +.env +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +nul diff --git a/midi-tools/README.md b/midi-tools/README.md new file mode 100644 index 0000000..a6c5820 --- /dev/null +++ b/midi-tools/README.md @@ -0,0 +1,206 @@ +--- + +# Tesla Coil MIDI Tools + +A collection of Python scripts designed to help manage and edit MIDI files for Tesla Coil projects. + +## Table of Contents + +- [midicheck.py](#midicheckpy) +- [redundancy_check.py](#redundancy_checkpy) +- [velfix.py](#velfixpy) +- [monofy.py](#monofypy) +- [baketempo.py](#baketempopy) +- [Requirements](#requirements) +- [Installation](#installation) +- [Contributing](#contributing) +- [Disclaimer](#disclaimer) + +--- + +## midicheck.py + +`midicheck.py` scans all MIDI files in a specified directory and generates a text file for each MIDI file, providing an overview of pertinent data. + +### Features + +- **Batch Processing:** Scans all MIDI files within a given directory. +- **Detailed Output:** Generates a separate text file for each MIDI file, summarizing key information. + +### Usage + +```bash +python midicheck.py c:\path\to\midis +``` + +### Example + +To scan MIDI files located in `C:\Users\YourName\Documents\MIDIs`, run: + +```bash +python midicheck.py C:\Users\YourName\Documents\MIDIs +``` + +--- + +## redundancy_check.py + +`redundancy_check.py` scans a MIDI file for any redundant data, such as repeating pitch data or unnecessary program changes. If any redundant data is found, it will be removed. + +### Features + +- **Redundancy Detection:** Identifies unnecessary repeating pitch data and program changes. +- **Automated Cleanup:** Removes any detected redundant information. +- **Output File:** Creates a cleaned MIDI file with the suffix `_redundancy_check.mid`. + +### Usage + +```bash +python redundancy_check.py midiname.mid +``` + +### Example + +To check and clean `song.mid`, run: + +```bash +python redundancy_check.py song.mid +``` + +This command will generate a file named `song_redundancy_check.mid`. + +**⚠️ Important:** Always test the output MIDI file before deleting the original to ensure no unintended changes were made. + +--- + +## velfix.py + +`velfix.py` modifies the velocity of every note in a MIDI file to a specified value, with the option to ignore certain MIDI channels. + +### Features + +- **Uniform Velocity Adjustment:** Sets all note velocities to a user-defined value. +- **Channel Exclusion:** Allows specifying channels to exclude from velocity changes. +- **Output File:** Creates a modified MIDI file with the suffix `_velfix.mid`. + +### Usage + +```bash +python velfix.py midiname.mid new_velocity [channels_to_ignore] +``` + +- `midiname.mid`: The MIDI file to be processed. +- `new_velocity`: The velocity value to set for all notes (0-127). +- `channels_to_ignore` (optional): Comma-separated list of MIDI channels to exclude from changes. + +### Example + +To set all note velocities to `127` except for notes on channel `2` in `matlock.mid`, run: + +```bash +python velfix.py matlock.mid 127 2 +``` + +This command will generate a file named `matlock_velfix.mid`, where all velocities are set to `127` except for those on channel `2`. + +**⚠️ Important:** Always test the output MIDI file before deleting the original to ensure the changes meet your expectations. + +--- + +## monofy.py + +`monofy.py` splits polyphonic tracks into multiple monophonic tracks while maintaining the same channel assignments. This tool is useful for splitting chords across multiple Tesla Coil setups. + +### Features + +- **Polyphonic to Monophonic Conversion:** Separates multiple notes on a single channel into individual monophonic tracks. +- **Channel Preservation:** Maintains the original channel assignments for each new track. +- **Output File:** Creates a modified MIDI file with the suffix `_monofied.mid`. + +### Usage + +```bash +python monofy.py midifile.mid +``` + +### Example + +To split the polyphonic tracks in `midifile.mid`, run: + +```bash +python monofy.py midifile.mid +``` + +This command will generate a file named `midifile_monofied.mid`. + +**⚠️ Important:** Always test the output MIDI file before deleting the original to ensure the changes meet your expectations. + +--- + +## baketempo.py + +`baketempo.py` is designed to process MIDI files by eliminating all tempo changes and embedding the playback speed directly into the absolute timing of MIDI events. This ensures that the resulting MIDI file maintains the original playback speed without relying on tempo change messages, thereby preventing discrepancies when imported into Digital Audio Workstations (DAWs). + +### Features + +- **Remove Tempo Changes:** Strips all tempo change messages from the MIDI file. +- **Preserve Playback Speed:** Recalculates delta times of all MIDI events based on a constant tempo derived from the original file's starting tempo. +- **Maintain Synchronization:** Ensures that multiple tracks remain perfectly synchronized without drifting. +- **Easy to Use:** Operates via the command line and outputs a new MIDI file with a simple naming convention. + +### Usage + +Run the script from the command line by providing the path to your input MIDI file. The script will generate a new MIDI file with `_tempo` appended to the original filename. + +```bash +python baketempo.py path/to/your_input_file.mid +``` + +### Example + +Suppose you have a MIDI file named `song.mid` located in the current directory. To bake its tempo, execute: + +```bash +python baketempo.py song.mid +``` + +This command will produce a new file named `song_tempo.mid` in the same directory. + +--- + +## Requirements + +- **Python:** Ensure you have Python installed on your system. You can download it from [python.org](https://www.python.org/downloads/). +- **mido Library:** This project relies on the `mido` MIDI library. + +## Installation + +1. **Clone the Repository:** + + ```bash + git clone http://gitea.opentesla.org/melancholytron/TMmidis.git + ``` + +2. **Navigate to the Project Directory:** + + ```bash + cd TMmidis + ``` + +3. **Install Required Python Libraries:** + + ```bash + pip install mido + ``` + +## Contributing + +Contributions are welcome! Please submit a pull request or open an issue to discuss changes. + +--- + +## Disclaimer + +**⚠️ Important:** Always back up your original MIDI files before performing any operations to prevent accidental data loss. + +--- \ No newline at end of file diff --git a/midi-tools/back-tempo.py b/midi-tools/back-tempo.py new file mode 100644 index 0000000..e9da93b --- /dev/null +++ b/midi-tools/back-tempo.py @@ -0,0 +1,171 @@ +import mido +from mido import MidiFile, MidiTrack, Message, MetaMessage +import sys +import os +import argparse + +def parse_args(): + parser = argparse.ArgumentParser(description='Bake tempo changes into MIDI file with constant tempo.') + parser.add_argument('input_file', type=str, help='Path to the input MIDI file.') + return parser.parse_args() + +class MIDIMessage: + def __init__(self, message, track, abs_tick, abs_time): + self.message = message + self.track = track + self.abs_tick = abs_tick + self.abs_time = abs_time + self.new_tick = None # To be calculated later + +def compute_absolute_times(mid): + """ + Compute absolute time for each message across all tracks, considering tempo changes. + Returns a list of MIDIMessage instances with absolute times. + """ + ticks_per_beat = mid.ticks_per_beat + DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) + + # Collect all messages with their absolute ticks and track index + all_msgs = [] + for track_index, track in enumerate(mid.tracks): + abs_tick = 0 + for msg in track: + abs_tick += msg.time + all_msgs.append(MIDIMessage(msg, track_index, abs_tick, 0.0)) + + # Sort all messages by absolute tick + all_msgs.sort(key=lambda m: m.abs_tick) + + # Now, compute absolute times + current_tempo = DEFAULT_TEMPO + abs_time = 0.0 + prev_tick = 0 + + for msg in all_msgs: + delta_ticks = msg.abs_tick - prev_tick + delta_time = mido.tick2second(delta_ticks, ticks_per_beat, current_tempo) + abs_time += delta_time + msg.abs_time = abs_time + + # If the message is a tempo change, update the current tempo + if msg.message.type == 'set_tempo': + current_tempo = msg.message.tempo + prev_tick = msg.abs_tick + + return all_msgs + +def bake_tempo(all_msgs, ticks_per_beat, constant_tempo): + """ + Assign new ticks to each message based on absolute time and constant tempo. + """ + for msg in all_msgs: + # Calculate new tick based on absolute time and constant tempo + # new_tick = abs_time / seconds_per_tick + # seconds_per_tick = constant_tempo / ticks_per_beat / 1e6 + seconds_per_tick = constant_tempo / ticks_per_beat / 1e6 + msg.new_tick = int(round(msg.abs_time / seconds_per_tick)) + + return all_msgs + +def assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat): + """ + Assign new delta ticks to each track based on the baked tempo. + Returns a list of tracks with updated messages. + """ + # Prepare a list for each track + new_tracks = [[] for _ in mid.tracks] + + # Sort messages back into their respective tracks + for msg in all_msgs: + if msg.message.type == 'set_tempo': + # Skip tempo messages + continue + new_tracks[msg.track].append(msg) + + # Now, for each track, sort messages by new_tick and assign delta ticks + for track_index, track_msgs in enumerate(new_tracks): + # Sort messages by new_tick + track_msgs.sort(key=lambda m: m.new_tick) + + # Assign delta ticks + prev_tick = 0 + new_track = [] + for msg in track_msgs: + delta_tick = msg.new_tick - prev_tick + prev_tick = msg.new_tick + + # Create a copy of the message to avoid modifying the original + new_msg = msg.message.copy(time=delta_tick) + new_track.append(new_msg) + + # Ensure the track ends with an end_of_track message + if not new_track or new_track[-1].type != 'end_of_track': + new_track.append(MetaMessage('end_of_track', time=0)) + + new_tracks[track_index] = new_track + + return new_tracks + +def get_initial_tempo(all_msgs, default_tempo=500000): + """ + Retrieve the initial tempo from the list of MIDI messages. + If no set_tempo message is found, return the default tempo. + """ + for msg in all_msgs: + if msg.message.type == 'set_tempo': + return msg.message.tempo + return default_tempo + +def main(): + args = parse_args() + input_path = args.input_file + + if not os.path.isfile(input_path): + print(f"Error: File '{input_path}' does not exist.") + sys.exit(1) + + try: + mid = MidiFile(input_path) + except Exception as e: + print(f"Error reading MIDI file: {e}") + sys.exit(1) + + ticks_per_beat = mid.ticks_per_beat + + # Compute absolute times for all messages + all_msgs = compute_absolute_times(mid) + + # Get the initial tempo (first set_tempo message or default) + initial_tempo = get_initial_tempo(all_msgs) + + # Bake the tempo by assigning new ticks based on constant tempo + all_msgs = bake_tempo(all_msgs, ticks_per_beat, initial_tempo) + + # Assign new delta ticks to each track + new_tracks = assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat) + + # Create a new MIDI file + new_mid = MidiFile(ticks_per_beat=ticks_per_beat) + + # Create a tempo track with the initial tempo + tempo_track = MidiTrack() + tempo_track.append(MetaMessage('set_tempo', tempo=initial_tempo, time=0)) + new_mid.tracks.append(tempo_track) + + # Add the updated performance tracks + for track in new_tracks: + new_mid.tracks.append(track) + + # Define output file name + base, ext = os.path.splitext(input_path) + output_path = f"{base}_tempo{ext}" + + try: + new_mid.save(output_path) + print(f"Successfully saved baked MIDI file as '{output_path}'.") + except Exception as e: + print(f"Error saving MIDI file: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/baketempo.py b/midi-tools/baketempo.py new file mode 100644 index 0000000..e9da93b --- /dev/null +++ b/midi-tools/baketempo.py @@ -0,0 +1,171 @@ +import mido +from mido import MidiFile, MidiTrack, Message, MetaMessage +import sys +import os +import argparse + +def parse_args(): + parser = argparse.ArgumentParser(description='Bake tempo changes into MIDI file with constant tempo.') + parser.add_argument('input_file', type=str, help='Path to the input MIDI file.') + return parser.parse_args() + +class MIDIMessage: + def __init__(self, message, track, abs_tick, abs_time): + self.message = message + self.track = track + self.abs_tick = abs_tick + self.abs_time = abs_time + self.new_tick = None # To be calculated later + +def compute_absolute_times(mid): + """ + Compute absolute time for each message across all tracks, considering tempo changes. + Returns a list of MIDIMessage instances with absolute times. + """ + ticks_per_beat = mid.ticks_per_beat + DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) + + # Collect all messages with their absolute ticks and track index + all_msgs = [] + for track_index, track in enumerate(mid.tracks): + abs_tick = 0 + for msg in track: + abs_tick += msg.time + all_msgs.append(MIDIMessage(msg, track_index, abs_tick, 0.0)) + + # Sort all messages by absolute tick + all_msgs.sort(key=lambda m: m.abs_tick) + + # Now, compute absolute times + current_tempo = DEFAULT_TEMPO + abs_time = 0.0 + prev_tick = 0 + + for msg in all_msgs: + delta_ticks = msg.abs_tick - prev_tick + delta_time = mido.tick2second(delta_ticks, ticks_per_beat, current_tempo) + abs_time += delta_time + msg.abs_time = abs_time + + # If the message is a tempo change, update the current tempo + if msg.message.type == 'set_tempo': + current_tempo = msg.message.tempo + prev_tick = msg.abs_tick + + return all_msgs + +def bake_tempo(all_msgs, ticks_per_beat, constant_tempo): + """ + Assign new ticks to each message based on absolute time and constant tempo. + """ + for msg in all_msgs: + # Calculate new tick based on absolute time and constant tempo + # new_tick = abs_time / seconds_per_tick + # seconds_per_tick = constant_tempo / ticks_per_beat / 1e6 + seconds_per_tick = constant_tempo / ticks_per_beat / 1e6 + msg.new_tick = int(round(msg.abs_time / seconds_per_tick)) + + return all_msgs + +def assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat): + """ + Assign new delta ticks to each track based on the baked tempo. + Returns a list of tracks with updated messages. + """ + # Prepare a list for each track + new_tracks = [[] for _ in mid.tracks] + + # Sort messages back into their respective tracks + for msg in all_msgs: + if msg.message.type == 'set_tempo': + # Skip tempo messages + continue + new_tracks[msg.track].append(msg) + + # Now, for each track, sort messages by new_tick and assign delta ticks + for track_index, track_msgs in enumerate(new_tracks): + # Sort messages by new_tick + track_msgs.sort(key=lambda m: m.new_tick) + + # Assign delta ticks + prev_tick = 0 + new_track = [] + for msg in track_msgs: + delta_tick = msg.new_tick - prev_tick + prev_tick = msg.new_tick + + # Create a copy of the message to avoid modifying the original + new_msg = msg.message.copy(time=delta_tick) + new_track.append(new_msg) + + # Ensure the track ends with an end_of_track message + if not new_track or new_track[-1].type != 'end_of_track': + new_track.append(MetaMessage('end_of_track', time=0)) + + new_tracks[track_index] = new_track + + return new_tracks + +def get_initial_tempo(all_msgs, default_tempo=500000): + """ + Retrieve the initial tempo from the list of MIDI messages. + If no set_tempo message is found, return the default tempo. + """ + for msg in all_msgs: + if msg.message.type == 'set_tempo': + return msg.message.tempo + return default_tempo + +def main(): + args = parse_args() + input_path = args.input_file + + if not os.path.isfile(input_path): + print(f"Error: File '{input_path}' does not exist.") + sys.exit(1) + + try: + mid = MidiFile(input_path) + except Exception as e: + print(f"Error reading MIDI file: {e}") + sys.exit(1) + + ticks_per_beat = mid.ticks_per_beat + + # Compute absolute times for all messages + all_msgs = compute_absolute_times(mid) + + # Get the initial tempo (first set_tempo message or default) + initial_tempo = get_initial_tempo(all_msgs) + + # Bake the tempo by assigning new ticks based on constant tempo + all_msgs = bake_tempo(all_msgs, ticks_per_beat, initial_tempo) + + # Assign new delta ticks to each track + new_tracks = assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat) + + # Create a new MIDI file + new_mid = MidiFile(ticks_per_beat=ticks_per_beat) + + # Create a tempo track with the initial tempo + tempo_track = MidiTrack() + tempo_track.append(MetaMessage('set_tempo', tempo=initial_tempo, time=0)) + new_mid.tracks.append(tempo_track) + + # Add the updated performance tracks + for track in new_tracks: + new_mid.tracks.append(track) + + # Define output file name + base, ext = os.path.splitext(input_path) + output_path = f"{base}_tempo{ext}" + + try: + new_mid.save(output_path) + print(f"Successfully saved baked MIDI file as '{output_path}'.") + except Exception as e: + print(f"Error saving MIDI file: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/midicheck.py b/midi-tools/midicheck.py new file mode 100644 index 0000000..a98d9e6 --- /dev/null +++ b/midi-tools/midicheck.py @@ -0,0 +1,306 @@ +import mido +import sys +import os +import argparse + +# General MIDI Program Numbers mapped to Instrument Names +# Program numbers in MIDI are 0-based (0-127) +GENERAL_MIDI_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" +] + +def get_instrument_name(program_number): + """ + Maps a MIDI program number to its instrument name. + MIDI program numbers are 0-based. + """ + if 0 <= program_number < len(GENERAL_MIDI_PROGRAMS): + return GENERAL_MIDI_PROGRAMS[program_number] + else: + return f"Unknown Program ({program_number})" + +def has_musical_messages(track): + """ + Determines if a MIDI track contains any musical messages. + Returns True if the track has at least one musical message, False otherwise. + """ + musical_types = { + 'note_on', 'note_off', 'program_change', 'control_change', + 'pitchwheel', 'aftertouch', 'polyphonic_key_pressure' + } + for msg in track: + if msg.type in musical_types: + return True + return False + +def collect_tempo_changes(midi): + """ + Collects all tempo changes from all tracks in the MIDI file. + Returns a list of tuples: (absolute_tick_time, bpm) + """ + tempo_changes = [] + ticks_per_beat = midi.ticks_per_beat + DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) is 120 BPM + + for track in midi.tracks: + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'set_tempo': + bpm = mido.tempo2bpm(msg.tempo) + tempo_changes.append((absolute_time, bpm)) + + # If no tempo changes found, assume default tempo + if not tempo_changes: + tempo_changes.append((0, mido.tempo2bpm(DEFAULT_TEMPO))) + + # Sort tempo changes by tick time + tempo_changes.sort(key=lambda x: x[0]) + + return tempo_changes + +def analyze_midi(file_path, output_path): + try: + midi = mido.MidiFile(file_path) + except IOError: + print(f" ❌ Could not open MIDI file: {file_path}") + return + except mido.KeySignatureError: + print(f" ❌ Invalid MIDI file: {file_path}") + return + + analysis = [] + + # Collect tempo changes + tempo_changes = collect_tempo_changes(midi) + tempos = [bpm for (_, bpm) in tempo_changes] + min_tempo = min(tempos) + max_tempo = max(tempos) + + # Prepare tempo data for output + if len(tempo_changes) == 1: + tempi = tempo_changes[0][1] # Assign tempo if only one + tempo_info = ( + f"Tempo Data:\n" + f" Tempo: {tempi:.2f} BPM\n" + ) + else: + tempo_info = ( + f"Tempo Data:\n" + f" Min Tempo: {min_tempo:.2f} BPM\n" + f" Max Tempo: {max_tempo:.2f} BPM\n" + f" Tempo Changes:\n" + ) + for idx, (tick, bpm) in enumerate(tempo_changes, start=1): + tempo_info += f" {idx}. {bpm:.2f} BPM at tick {tick}\n" + + analysis.append(tempo_info) + + musical_track_count = 0 # To number only musical tracks + # Initialize per channel RPN state and pitch bend range + channel_rpn_state = {channel: {'selected_rpn_msb': None, 'selected_rpn_lsb': None, 'rpn_selected': None} for channel in range(16)} + channel_pitch_bend_range = {channel: 2 for channel in range(16)} # Default to ±2 semitones + + for i, track in enumerate(midi.tracks): + if not has_musical_messages(track): + continue # Skip non-musical tracks + + musical_track_count += 1 + track_name = f"Track {musical_track_count}" # Number musical tracks sequentially + channels_used = set() + max_velocity = None + min_velocity = None + program_changes = [] # List to store program change events + pitch_bends_semitones = [] # List to store pitch bend semitone shifts + + absolute_time = 0 # To keep track of the absolute time in ticks + + for msg in track: + absolute_time += msg.time # Accumulate delta times to get absolute time + if msg.type == 'track_name': + track_name = msg.name + elif hasattr(msg, 'channel'): + channel = msg.channel # 0-15 + channels_used.add(channel + 1) # Channels are 0-15 in mido, represent as 1-16 + if msg.type == 'note_on' and msg.velocity > 0: + velocity = msg.velocity + if max_velocity is None or velocity > max_velocity: + max_velocity = velocity + if min_velocity is None or velocity < min_velocity: + min_velocity = velocity + elif msg.type == 'program_change': + # Store program number and the absolute time it occurs + program_changes.append((msg.program, absolute_time)) + elif msg.type == 'control_change': + # Handle RPN messages for Pitch Bend Sensitivity + if msg.control == 101: + # Set RPN MSB + channel_rpn_state[channel]['selected_rpn_msb'] = msg.value + if msg.value != 0: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 100: + # Set RPN LSB + channel_rpn_state[channel]['selected_rpn_lsb'] = msg.value + # Check if RPN 0 (Pitch Bend Sensitivity) is selected + if (channel_rpn_state[channel]['selected_rpn_msb'] == 0 and + channel_rpn_state[channel]['selected_rpn_lsb'] == 0): + channel_rpn_state[channel]['rpn_selected'] = 'pitch_bend_range' + else: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 6: + # Data Entry MSB + if channel_rpn_state[channel].get('rpn_selected') == 'pitch_bend_range': + # Set pitch bend range in semitones (integer part) + channel_pitch_bend_range[channel] = msg.value + elif msg.control == 38: + # Data Entry LSB + # Currently not handling fractional semitones + pass + elif msg.type == 'pitchwheel': + # Calculate semitone shift based on current pitch bend range + current_range = channel_pitch_bend_range[channel] + semitones = (msg.pitch / 8192) * current_range + pitch_bends_semitones.append(semitones) + + channels_used = sorted(channels_used) + channels_str = ', '.join(map(str, channels_used)) if channels_used else 'None' + has_program_change = 'Yes' if program_changes else 'No' + max_velocity_str = str(max_velocity) if max_velocity is not None else 'N/A' + min_velocity_str = str(min_velocity) if min_velocity is not None else 'N/A' + + # Analyze pitch bends + if pitch_bends_semitones: + min_pitch_bend = min(pitch_bends_semitones) + max_pitch_bend = max(pitch_bends_semitones) + pitch_shift_info = ( + f" Min Pitch Bend: {min_pitch_bend:.2f} semitones\n" + f" Max Pitch Bend: {max_pitch_bend:.2f} semitones\n" + ) + else: + pitch_shift_info = ( + f" Min Pitch Bend: N/A\n" + f" Max Pitch Bend: N/A\n" + ) + + # Gather Pitch Bend Sensitivity per channel used in this track + if channels_used: + pitch_bend_sensitivity_info = " Pitch Bend Sensitivity:\n" + for channel in channels_used: + sensitivity = channel_pitch_bend_range[channel - 1] + pitch_bend_sensitivity_info += f" Channel {channel}: {sensitivity:.2f} semitones\n" + else: + pitch_bend_sensitivity_info = " Pitch Bend Sensitivity: N/A\n" + + track_info = ( + f"{track_name}:\n" + f" Channels Used: {channels_str}\n" + f"{pitch_bend_sensitivity_info}" + f" Max Note Velocity: {max_velocity_str}\n" + f" Min Note Velocity: {min_velocity_str}\n" + f" Uses Program Change: {has_program_change}\n" + f"{pitch_shift_info}" + ) + + # If there are program changes, add their details + if program_changes: + track_info += f" Program Changes:\n" + for idx, (program, time) in enumerate(program_changes, start=1): + instrument_name = get_instrument_name(program) + track_info += f" {idx}. Program {program} ({instrument_name}) at tick {time}\n" + + analysis.append(track_info) + + if musical_track_count == 0: + print(f" ⚠️ No musical tracks found in MIDI file: {file_path}") + return + + try: + with open(output_path, 'w', encoding='utf-8') as f: + f.write(f"MIDI File Analysis: {os.path.basename(file_path)}\n\n") + for section in analysis: + f.write(section + "\n") + print(f" ✅ Analysis complete. Results saved to '{output_path}'") + except IOError: + print(f" ❌ Could not write to output file: {output_path}") + +def process_directory(input_dir, recursive=False): + if recursive: + midi_files = [ + os.path.join(dp, f) for dp, dn, filenames in os.walk(input_dir) + for f in filenames if f.lower().endswith(('.mid', '.midi')) + ] + else: + midi_files = [ + os.path.join(input_dir, f) for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.mid', '.midi')) + ] + + if not midi_files: + print(f"No MIDI files found in directory: {input_dir}") + return + + print(f"Found {len(midi_files)} MIDI file(s) in '{input_dir}'{' and its subdirectories' if recursive else ''}.\n") + + for midi_file in midi_files: + base, _ = os.path.splitext(midi_file) + output_text = base + '.txt' # Replace extension with .txt + print(f"Analyzing '{midi_file}'...") + analyze_midi(midi_file, output_text) + +def main(): + parser = argparse.ArgumentParser( + description="Analyze all MIDI files in a directory and generate corresponding text reports." + ) + parser.add_argument( + 'input_directory', + help="Path to the directory containing MIDI files to analyze." + ) + parser.add_argument( + '-r', '--recursive', + action='store_true', + help="Recursively analyze MIDI files in all subdirectories." + ) + + args = parser.parse_args() + + input_dir = args.input_directory + + if not os.path.isdir(input_dir): + print(f"Error: The provided path is not a directory or does not exist: {input_dir}") + sys.exit(1) + + process_directory(input_dir, recursive=args.recursive) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/midicheckjson.py b/midi-tools/midicheckjson.py new file mode 100644 index 0000000..869648b --- /dev/null +++ b/midi-tools/midicheckjson.py @@ -0,0 +1,283 @@ +import mido +import sys +import os +import argparse +import json + +# General MIDI Program Numbers mapped to Instrument Names +GENERAL_MIDI_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" +] + +def get_instrument_name(program_number): + """ + Maps a MIDI program number to its instrument name. + MIDI program numbers are 0-based. + """ + if 0 <= program_number < len(GENERAL_MIDI_PROGRAMS): + return GENERAL_MIDI_PROGRAMS[program_number] + else: + return f"Unknown Program ({program_number})" + +def has_musical_messages(track): + """ + Determines if a MIDI track contains any musical messages. + Returns True if the track has at least one musical message, False otherwise. + """ + musical_types = { + 'note_on', 'note_off', 'program_change', 'control_change', + 'pitchwheel', 'aftertouch', 'polyphonic_key_pressure' + } + for msg in track: + if msg.type in musical_types: + return True + return False + +def collect_tempo_changes(midi): + """ + Collects all tempo changes from all tracks in the MIDI file. + Returns a list of tuples: (absolute_tick_time, bpm) + """ + tempo_changes = [] + ticks_per_beat = midi.ticks_per_beat + DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) is 120 BPM + + for track in midi.tracks: + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'set_tempo': + bpm = mido.tempo2bpm(msg.tempo) + tempo_changes.append((absolute_time, bpm)) + + # If no tempo changes found, assume default tempo + if not tempo_changes: + tempo_changes.append((0, mido.tempo2bpm(DEFAULT_TEMPO))) + + # Sort tempo changes by tick time + tempo_changes.sort(key=lambda x: x[0]) + + return tempo_changes + +def analyze_midi(file_path): + try: + midi = mido.MidiFile(file_path) + except IOError: + print(f" ❌ Could not open MIDI file: {file_path}") + return + except mido.KeySignatureError: + print(f" ❌ Invalid MIDI file: {file_path}") + return + + analysis = { + "song_title": os.path.splitext(os.path.basename(file_path))[0], + "tempo": { + "min_bpm": None, + "max_bpm": None + }, + "pitch_bend": { + "min_semitones": None, + "max_semitones": None + }, + "tracks": [], + "notes": "" + } + + # Collect tempo changes + tempo_changes = collect_tempo_changes(midi) + tempos = [bpm for (_, bpm) in tempo_changes] + analysis["tempo"]["min_bpm"] = min(tempos) + analysis["tempo"]["max_bpm"] = max(tempos) + + musical_track_count = 0 # To number only musical tracks + # Initialize per channel RPN state and pitch bend range + channel_rpn_state = {channel: {'selected_rpn_msb': None, 'selected_rpn_lsb': None, 'rpn_selected': None} for channel in range(16)} + channel_pitch_bend_range = {channel: 2 for channel in range(16)} # Default to ±2 semitones + + global_pitch_bends = [] + + for track in midi.tracks: + if not has_musical_messages(track): + continue # Skip non-musical tracks + + musical_track_count += 1 + track_info = { + "track_name": f"Track {musical_track_count}", + "Channel Assignment": [], + "Pitch Bend Sensitivity": {}, + "Max Note Velocity": "N/A", + "Min Note Velocity": "N/A", + "Uses Program Change": False + } + + program_changes = [] + pitch_bends_semitones = [] + + absolute_time = 0 # To keep track of the absolute time in ticks + + for msg in track: + absolute_time += msg.time # Accumulate delta times to get absolute time + if msg.type == 'track_name': + track_info["track_name"] = msg.name + elif hasattr(msg, 'channel'): + channel = msg.channel # 0-15 + if (channel + 1) not in track_info["Channel Assignment"]: + track_info["Channel Assignment"].append(channel + 1) # Channels represented as 1-16 + + if msg.type == 'note_on' and msg.velocity > 0: + velocity = msg.velocity + if track_info["Max Note Velocity"] == "N/A" or velocity > track_info["Max Note Velocity"]: + track_info["Max Note Velocity"] = velocity + if track_info["Min Note Velocity"] == "N/A" or velocity < track_info["Min Note Velocity"]: + track_info["Min Note Velocity"] = velocity + elif msg.type == 'program_change': + track_info["Uses Program Change"] = True + program_changes.append({ + "program_number": msg.program, + "instrument_name": get_instrument_name(msg.program), + "tick": absolute_time + }) + elif msg.type == 'control_change': + # Handle RPN messages for Pitch Bend Sensitivity + if msg.control == 101: + # Set RPN MSB + channel_rpn_state[channel]['selected_rpn_msb'] = msg.value + if msg.value != 0: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 100: + # Set RPN LSB + channel_rpn_state[channel]['selected_rpn_lsb'] = msg.value + # Check if RPN 0 (Pitch Bend Sensitivity) is selected + if (channel_rpn_state[channel]['selected_rpn_msb'] == 0 and + channel_rpn_state[channel]['selected_rpn_lsb'] == 0): + channel_rpn_state[channel]['rpn_selected'] = 'pitch_bend_range' + else: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 6: + # Data Entry MSB + if channel_rpn_state[channel].get('rpn_selected') == 'pitch_bend_range': + # Set pitch bend range in semitones (integer part) + channel_pitch_bend_range[channel] = msg.value + elif msg.control == 38: + # Data Entry LSB + # Currently not handling fractional semitones + pass + elif msg.type == 'pitchwheel': + # Calculate semitone shift based on current pitch bend range + current_range = channel_pitch_bend_range[channel] + semitones = (msg.pitch / 8192) * current_range + pitch_bends_semitones.append(semitones) + global_pitch_bends.append(semitones) + + # Update Pitch Bend Sensitivity for channels used in this track + for channel in track_info["Channel Assignment"]: + sensitivity = channel_pitch_bend_range[channel - 1] + track_info["Pitch Bend Sensitivity"][f"Channel {channel}"] = round(sensitivity, 2) + + # Analyze pitch bends + if pitch_bends_semitones: + track_info["pitch_bend"] = { + "min_semitones": round(min(pitch_bends_semitones), 2), + "max_semitones": round(max(pitch_bends_semitones), 2) + } + else: + track_info["pitch_bend"] = { + "min_semitones": None, + "max_semitones": None + } + + # Add program changes if any + if program_changes: + track_info["Program Changes"] = program_changes + + analysis["tracks"].append(track_info) + + # Analyze global pitch bends + if global_pitch_bends: + analysis["pitch_bend"]["min_semitones"] = round(min(global_pitch_bends), 2) + analysis["pitch_bend"]["max_semitones"] = round(max(global_pitch_bends), 2) + else: + analysis["pitch_bend"]["min_semitones"] = None + analysis["pitch_bend"]["max_semitones"] = None + + if musical_track_count == 0: + print(f" ⚠️ No musical tracks found in MIDI file: {file_path}") + return + + # Write the analysis to a JSON file + output_path = os.path.splitext(file_path)[0] + '.json' + try: + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(analysis, f, indent=4) + print(f" ✅ Analysis complete. Results saved to '{output_path}'") + except IOError: + print(f" ❌ Could not write to output file: {output_path}") + +def process_directory(input_dir): + midi_files = [ + os.path.join(dp, f) for dp, dn, filenames in os.walk(input_dir) + for f in filenames if f.lower().endswith(('.mid', '.midi')) + ] + + if not midi_files: + print(f"No MIDI files found in directory: {input_dir}") + return + + print(f"Found {len(midi_files)} MIDI file(s) in '{input_dir}' and its subdirectories.\n") + + for midi_file in midi_files: + print(f"Analyzing '{midi_file}'...") + analyze_midi(midi_file) + +def main(): + parser = argparse.ArgumentParser( + description="Analyze all MIDI files in a directory and generate corresponding JSON reports." + ) + parser.add_argument( + 'input_directory', + help="Path to the directory containing MIDI files to analyze." + ) + + args = parser.parse_args() + + input_dir = args.input_directory + + if not os.path.isdir(input_dir): + print(f"Error: The provided path is not a directory or does not exist: {input_dir}") + sys.exit(1) + + process_directory(input_dir) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/midicheckjson_single.py b/midi-tools/midicheckjson_single.py new file mode 100644 index 0000000..fdbce35 --- /dev/null +++ b/midi-tools/midicheckjson_single.py @@ -0,0 +1,275 @@ +import mido +import sys +import os +import argparse +import json + +# General MIDI Program Numbers mapped to Instrument Names +GENERAL_MIDI_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" +] + +def get_instrument_name(program_number): + """ + Maps a MIDI program number to its instrument name. + MIDI program numbers are 0-based. + """ + if 0 <= program_number < len(GENERAL_MIDI_PROGRAMS): + return GENERAL_MIDI_PROGRAMS[program_number] + else: + return f"Unknown Program ({program_number})" + +def has_musical_messages(track): + """ + Determines if a MIDI track contains any musical messages. + Returns True if the track has at least one musical message, False otherwise. + """ + musical_types = { + 'note_on', 'note_off', 'program_change', 'control_change', + 'pitchwheel', 'aftertouch', 'polyphonic_key_pressure' + } + for msg in track: + if msg.type in musical_types: + return True + return False + +def collect_tempo_changes(midi): + """ + Collects all tempo changes from all tracks in the MIDI file. + Returns a list of tuples: (absolute_tick_time, bpm) + """ + tempo_changes = [] + ticks_per_beat = midi.ticks_per_beat + DEFAULT_TEMPO = 500000 # Default tempo (microseconds per beat) is 120 BPM + + for track in midi.tracks: + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'set_tempo': + bpm = mido.tempo2bpm(msg.tempo) + tempo_changes.append((absolute_time, bpm)) + + # If no tempo changes found, assume default tempo + if not tempo_changes: + tempo_changes.append((0, mido.tempo2bpm(DEFAULT_TEMPO))) + + # Sort tempo changes by tick time + tempo_changes.sort(key=lambda x: x[0]) + + return tempo_changes + +def analyze_midi(file_path): + try: + midi = mido.MidiFile(file_path) + except IOError: + print(f"Error: Could not open MIDI file: {file_path}") + return False + except mido.KeySignatureError: + print(f"Error: Invalid MIDI file: {file_path}") + return False + + analysis = { + "song_title": os.path.splitext(os.path.basename(file_path))[0], + "tempo": { + "min_bpm": None, + "max_bpm": None + }, + "pitch_bend": { + "min_semitones": None, + "max_semitones": None + }, + "tracks": [], + "notes": "", + "song_offset": 0 + } + + # Collect tempo changes + tempo_changes = collect_tempo_changes(midi) + tempos = [bpm for (_, bpm) in tempo_changes] + analysis["tempo"]["min_bpm"] = min(tempos) + analysis["tempo"]["max_bpm"] = max(tempos) + + musical_track_count = 0 # To number only musical tracks + # Initialize per channel RPN state and pitch bend range + channel_rpn_state = {channel: {'selected_rpn_msb': None, 'selected_rpn_lsb': None, 'rpn_selected': None} for channel in range(16)} + channel_pitch_bend_range = {channel: 2 for channel in range(16)} # Default to ±2 semitones + + global_pitch_bends = [] + + for track in midi.tracks: + if not has_musical_messages(track): + continue # Skip non-musical tracks + + musical_track_count += 1 + track_info = { + "track_name": f"Track {musical_track_count}", + "Channel Assignment": [], + "Pitch Bend Sensitivity": {}, + "Max Note Velocity": "N/A", + "Min Note Velocity": "N/A", + "Uses Program Change": False + } + + program_changes = [] + pitch_bends_semitones = [] + + absolute_time = 0 # To keep track of the absolute time in ticks + + for msg in track: + absolute_time += msg.time # Accumulate delta times to get absolute time + if msg.type == 'track_name': + track_info["track_name"] = msg.name + elif hasattr(msg, 'channel'): + channel = msg.channel # 0-15 + if (channel + 1) not in track_info["Channel Assignment"]: + track_info["Channel Assignment"].append(channel + 1) # Channels represented as 1-16 + + if msg.type == 'note_on' and msg.velocity > 0: + velocity = msg.velocity + if track_info["Max Note Velocity"] == "N/A" or velocity > track_info["Max Note Velocity"]: + track_info["Max Note Velocity"] = velocity + if track_info["Min Note Velocity"] == "N/A" or velocity < track_info["Min Note Velocity"]: + track_info["Min Note Velocity"] = velocity + elif msg.type == 'program_change': + track_info["Uses Program Change"] = True + program_changes.append({ + "program_number": msg.program, + "instrument_name": get_instrument_name(msg.program), + "tick": absolute_time + }) + elif msg.type == 'control_change': + # Handle RPN messages for Pitch Bend Sensitivity + if msg.control == 101: + # Set RPN MSB + channel_rpn_state[channel]['selected_rpn_msb'] = msg.value + if msg.value != 0: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 100: + # Set RPN LSB + channel_rpn_state[channel]['selected_rpn_lsb'] = msg.value + # Check if RPN 0 (Pitch Bend Sensitivity) is selected + if (channel_rpn_state[channel]['selected_rpn_msb'] == 0 and + channel_rpn_state[channel]['selected_rpn_lsb'] == 0): + channel_rpn_state[channel]['rpn_selected'] = 'pitch_bend_range' + else: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 6: + # Data Entry MSB + if channel_rpn_state[channel].get('rpn_selected') == 'pitch_bend_range': + # Set pitch bend range in semitones (integer part) + channel_pitch_bend_range[channel] = msg.value + elif msg.control == 38: + # Data Entry LSB + # Currently not handling fractional semitones + pass + elif msg.type == 'pitchwheel': + # Calculate semitone shift based on current pitch bend range + current_range = channel_pitch_bend_range[channel] + semitones = (msg.pitch / 8192) * current_range + pitch_bends_semitones.append(semitones) + global_pitch_bends.append(semitones) + + # Update Pitch Bend Sensitivity for channels used in this track + for channel in track_info["Channel Assignment"]: + sensitivity = channel_pitch_bend_range[channel - 1] + track_info["Pitch Bend Sensitivity"][f"Channel {channel}"] = round(sensitivity, 2) + + # Analyze pitch bends + if pitch_bends_semitones: + track_info["pitch_bend"] = { + "min_semitones": round(min(pitch_bends_semitones), 2), + "max_semitones": round(max(pitch_bends_semitones), 2) + } + else: + track_info["pitch_bend"] = { + "min_semitones": 0.0, + "max_semitones": 0.0 + } + + # Add program changes if any + if program_changes: + track_info["Program Changes"] = program_changes + + analysis["tracks"].append(track_info) + + # Analyze global pitch bends + if global_pitch_bends: + analysis["pitch_bend"]["min_semitones"] = round(min(global_pitch_bends), 2) + analysis["pitch_bend"]["max_semitones"] = round(max(global_pitch_bends), 2) + else: + analysis["pitch_bend"]["min_semitones"] = 0.0 + analysis["pitch_bend"]["max_semitones"] = 0.0 + + if musical_track_count == 0: + print(f"Warning: No musical tracks found in MIDI file: {file_path}") + return False + + # Write the analysis to a JSON file + output_path = os.path.splitext(file_path)[0] + '.json' + try: + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(analysis, f, indent=4, ensure_ascii=False) + print(f"Analysis complete. Results saved to '{output_path}'") + return True + except IOError: + print(f"Error: Could not write to output file: {output_path}") + return False + +def main(): + parser = argparse.ArgumentParser( + description="Analyze a single MIDI file and generate a JSON report." + ) + parser.add_argument( + 'input_file', + help="Path to the MIDI file to analyze." + ) + + args = parser.parse_args() + + input_file = args.input_file + + if not os.path.isfile(input_file): + print(f"Error: The provided path is not a file or does not exist: {input_file}") + sys.exit(1) + + if not input_file.lower().endswith(('.mid', '.midi')): + print(f"Error: The provided file is not a MIDI file: {input_file}") + sys.exit(1) + + success = analyze_midi(input_file) + sys.exit(0 if success else 1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/monofy.py b/midi-tools/monofy.py new file mode 100644 index 0000000..9c1b2c7 --- /dev/null +++ b/midi-tools/monofy.py @@ -0,0 +1,178 @@ +import mido +from mido import MidiFile, MidiTrack, Message, MetaMessage +import sys +import os +from collections import defaultdict + +class Note: + def __init__(self, note, start, end, channel, velocity_on, velocity_off): + self.note = note + self.start = start + self.end = end + self.channel = channel + self.velocity_on = velocity_on + self.velocity_off = velocity_off + self.voice = None + +def get_notes_and_events(track): + """ + Extract notes with start and end times and collect non-note events from a MIDI track. + Returns a list of Note objects and a list of non-note events. + Each non-note event is a tuple (absolute_time, message). + """ + notes = [] + ongoing_notes = {} + non_note_events = [] + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'note_on' and msg.velocity > 0: + key = (msg.note, msg.channel) + if key in ongoing_notes: + print(f"Warning: Note {msg.note} on channel {msg.channel} started without previous note_off.") + ongoing_notes[key] = (absolute_time, msg.velocity) + elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): + key = (msg.note, msg.channel) + if key in ongoing_notes: + start_time, velocity_on = ongoing_notes.pop(key) + notes.append(Note(msg.note, start_time, absolute_time, msg.channel, velocity_on, msg.velocity)) + else: + print(f"Warning: Note {msg.note} on channel {msg.channel} ended without a start.") + else: + # Collect non-note events + non_note_events.append((absolute_time, msg.copy(time=0))) # Use time=0 temporarily + return notes, non_note_events + +def assign_voices(notes): + """ + Assign voices to notes based on overlapping. + Lower pitch notes get lower voice numbers. + Returns notes with assigned voice and total number of voices. + """ + # Sort notes by start time, then by pitch (ascending) + notes.sort(key=lambda x: (x.start, x.note)) + + voices = [] # List of end times for each voice + for note in notes: + # Find available voices where the current voice's last note ends before the new note starts + available = [i for i, end in enumerate(voices) if end <= note.start] + if available: + # Assign to the lowest available voice + voice = min(available) + voices[voice] = note.end + else: + # No available voice, create a new one + voice = len(voices) + voices.append(note.end) + note.voice = voice + return notes, len(voices) + +def create_track_name(original_name, voice): + if voice == 0: + return original_name + else: + return f"{original_name}_{voice}" + +def merge_events(note_events, non_note_events, voice): + """ + Merge note events and non-note events for a specific voice. + Returns a list of messages sorted by absolute time. + """ + events = [] + + # Add non-note events + for abs_time, msg in non_note_events: + events.append((abs_time, msg)) + + # Add note_on and note_off events for this voice + for note in note_events: + if note.voice != voice: + continue + # Note on + events.append((note.start, Message('note_on', note=note.note, velocity=note.velocity_on, channel=note.channel, time=0))) + # Note off + events.append((note.end, Message('note_off', note=note.note, velocity=note.velocity_off, channel=note.channel, time=0))) + + # Sort all events by absolute time + events.sort(key=lambda x: x[0]) + + # Convert absolute times to delta times + merged_msgs = [] + prev_time = 0 + for abs_time, msg in events: + delta = abs_time - prev_time + msg.time = delta + merged_msgs.append(msg) + prev_time = abs_time + + return merged_msgs + +def process_track(track, original_track_index): + """ + Process a single MIDI track and split overlapping notes into new tracks. + Preserves non-note messages by copying them to each suffixed track. + Returns a list of new tracks. + """ + # Extract track name + track_name = f"Track{original_track_index}" + for msg in track: + if msg.type == 'track_name': + track_name = msg.name + break + + # Extract notes and non-note events + notes, non_note_events = get_notes_and_events(track) + if not notes: + return [track] # No notes to process + + # Assign voices + assigned_notes, num_voices = assign_voices(notes) + + # Collect notes per voice + voice_to_notes = defaultdict(list) + for note in assigned_notes: + voice_to_notes[note.voice].append(note) + + # Create new tracks + new_tracks = [] + for voice in range(num_voices): + new_track = MidiTrack() + # Add track name + new_track.append(MetaMessage('track_name', name=create_track_name(track_name, voice), time=0)) + # Merge and sort events + merged_msgs = merge_events(voice_to_notes[voice], non_note_events, voice) + new_track.extend(merged_msgs) + new_tracks.append(new_track) + return new_tracks + +def generate_output_filename(input_file): + """ + Generate output filename by inserting '_monofy' before the file extension. + For example: 'song.mid' -> 'song_monofy.mid' + """ + base, ext = os.path.splitext(input_file) + return f"{base}_monofy{ext}" + +def main(input_file): + output_file = generate_output_filename(input_file) + + mid = MidiFile(input_file) + new_mid = MidiFile() + new_mid.ticks_per_beat = mid.ticks_per_beat + + for i, track in enumerate(mid.tracks): + new_tracks = process_track(track, i) + new_mid.tracks.extend(new_tracks) + + new_mid.save(output_file) + print(f"Processed MIDI saved to '{output_file}'") + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python split_midi_tracks.py input.mid") + else: + input_file = sys.argv[1] + if not os.path.isfile(input_file): + print(f"Input file '{input_file}' does not exist.") + sys.exit(1) + main(input_file) \ No newline at end of file diff --git a/midi-tools/reduncheck.py b/midi-tools/reduncheck.py new file mode 100644 index 0000000..1a6aec4 --- /dev/null +++ b/midi-tools/reduncheck.py @@ -0,0 +1,122 @@ +import mido +import argparse +import sys +import os + +def remove_redundant_midi(input_path, verbose=False): + """ + Removes redundant MIDI data from the input MIDI file and saves the cleaned file + with '_redundancy_check' appended to the original filename. + + Args: + input_path (str): Path to the input MIDI file. + verbose (bool): If True, prints detailed processing information. + """ + try: + midi = mido.MidiFile(input_path) + if verbose: + print(f"Loaded MIDI file '{input_path}' successfully.") + except IOError: + print(f"Error: Cannot open input MIDI file '{input_path}'. Please check the path.") + sys.exit(1) + except mido.KeySignatureError as e: + print(f"Error reading MIDI file: {e}") + sys.exit(1) + + cleaned_midi = mido.MidiFile() + cleaned_midi.ticks_per_beat = midi.ticks_per_beat + + total_messages = 0 + removed_messages = 0 + + for i, track in enumerate(midi.tracks): + cleaned_track = mido.MidiTrack() + last_state = {} # To keep track of the last control change or similar messages + last_msg = None + track_removed = 0 + track_total = 0 + + for msg in track: + track_total += 1 + total_messages += 1 + + # Remove consecutive duplicate messages + if last_msg is not None and msg == last_msg: + if verbose: + print(f"Track {i}: Removed duplicate message {msg}") + removed_messages += 1 + track_removed += 1 + continue + + # Remove redundant control change messages + if msg.type in ['control_change', 'program_change', 'pitchwheel', 'aftertouch', 'channel_pressure']: + channel = msg.channel + key = f"{msg.type}_{channel}" + if key in last_state and last_state[key] == msg: + if verbose: + print(f"Track {i}: Removed redundant state message {msg}") + removed_messages += 1 + track_removed += 1 + continue # Redundant state message + last_state[key] = msg + cleaned_track.append(msg) + # Remove redundant note_off messages if note_on with velocity 0 is used + elif msg.type == 'note_off': + # Depending on MIDI implementation, decide if it's redundant + # For this script, we'll assume they are necessary and keep them + cleaned_track.append(msg) + elif msg.type == 'note_on' and msg.velocity == 0: + # This is equivalent to note_off; decide whether to keep or remove note_off messages + # For simplicity, keep both + cleaned_track.append(msg) + else: + # For all other message types, simply append if not duplicate + cleaned_track.append(msg) + + last_msg = msg + + cleaned_midi.tracks.append(cleaned_track) + if verbose: + print(f"Track {i}: Processed {track_total} messages, removed {track_removed} redundant messages.") + + # Generate the output file name by appending '_redundancy_check' before the file extension + base, ext = os.path.splitext(input_path) + output_path = f"{base}_redundancy_check{ext}" + + try: + cleaned_midi.save(output_path) + if verbose: + print(f"Saved cleaned MIDI to '{output_path}'.") + print(f"Total messages processed: {total_messages}") + print(f"Total messages removed: {removed_messages}") + else: + print(f"Successfully saved cleaned MIDI to '{output_path}'.") + except IOError: + print(f"Error: Cannot write to output file '{output_path}'. Please check the path and permissions.") + sys.exit(1) + +def parse_arguments(): + """ + Parses command-line arguments. + + Returns: + argparse.Namespace: The parsed arguments. + """ + parser = argparse.ArgumentParser(description="Remove redundant MIDI data from a MIDI file and save the cleaned file with '_redundancy_check' appended to the filename.") + parser.add_argument('input_midi', type=str, help='Path to the input MIDI file.') + parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output.') + + return parser.parse_args() + +def main(): + args = parse_arguments() + + # Check if input file exists + if not os.path.isfile(args.input_midi): + print(f"Error: The input file '{args.input_midi}' does not exist.") + sys.exit(1) + + remove_redundant_midi(args.input_midi, args.verbose) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/type_0.py b/midi-tools/type_0.py new file mode 100644 index 0000000..bb703dc --- /dev/null +++ b/midi-tools/type_0.py @@ -0,0 +1,61 @@ +import sys +import os +from mido import MidiFile, MidiTrack, merge_tracks + +def convert_to_type0(input_filepath): + """ + Converts a MIDI file to Type 0 and saves it with "_0" appended to the original filename. + + Parameters: + input_filepath (str): Path to the input MIDI file. + + Returns: + output_filepath (str): Path to the converted Type 0 MIDI file. + """ + try: + # Load the original MIDI file + midi = MidiFile(input_filepath) + print(f"Loaded MIDI file: {input_filepath}") + print(f"Original MIDI type: {midi.type}") + print(f"Number of tracks: {len(midi.tracks)}") + + # Merge all tracks into one + merged_track = merge_tracks(midi.tracks) + print("Merged all tracks into a single track for Type 0 MIDI.") + + # Create a new MIDI file with one track + type0_midi = MidiFile() + type0_midi.type = 0 # Explicitly set to Type 0 + type0_midi.ticks_per_beat = midi.ticks_per_beat # Preserve timing + + type0_midi.tracks.append(MidiTrack(merged_track)) + + # Prepare output file name + base, ext = os.path.splitext(input_filepath) + output_filepath = f"{base}_0{ext}" + + # Save the new Type 0 MIDI file + type0_midi.save(output_filepath) + print(f"Type 0 MIDI file saved as: {output_filepath}") + + return output_filepath + + except Exception as e: + print(f"An error occurred: {e}") + sys.exit(1) + +def main(): + if len(sys.argv) != 2: + print("Usage: python midi_to_type0.py ") + sys.exit(1) + + input_filepath = sys.argv[1] + + if not os.path.isfile(input_filepath): + print(f"Error: File '{input_filepath}' does not exist.") + sys.exit(1) + + convert_to_type0(input_filepath) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/midi-tools/velfix.py b/midi-tools/velfix.py new file mode 100644 index 0000000..0e65d53 --- /dev/null +++ b/midi-tools/velfix.py @@ -0,0 +1,154 @@ +import mido +import argparse +import sys +import os + +def set_all_velocities(input_path, new_velocity, ignored_channels=None, verbose=False): + """ + Sets the velocity of all note_on and note_off messages in a MIDI file to a specified value, + ignoring specified channels, and saves the modified MIDI file with '_velfix' appended to the original filename. + + Args: + input_path (str): Path to the input MIDI file. + new_velocity (int): The new velocity value (0-127) to set. + ignored_channels (set[int], optional): Set of MIDI channels to ignore (1-16). Defaults to None. + verbose (bool): If True, prints detailed processing information. + """ + try: + midi = mido.MidiFile(input_path) + if verbose: + print(f"Loaded MIDI file '{input_path}' successfully.") + except IOError: + print(f"Error: Cannot open input MIDI file '{input_path}'. Please check the path.") + sys.exit(1) + except mido.KeySignatureError as e: + print(f"Error reading MIDI file: {e}") + sys.exit(1) + + total_messages = 0 + modified_messages = 0 + ignored_messages = 0 + + for track in midi.tracks: + for msg in track: + total_messages += 1 + # Check if the message has a velocity attribute and a channel + if msg.type in ['note_on', 'note_off'] and msg.type == 'note_on' or msg.type == 'note_off': + # Some messages might not have a channel attribute + if hasattr(msg, 'channel'): + # MIDI channels are 0-15 in mido, so convert ignored_channels from 1-16 to 0-15 + msg_channel = msg.channel + 1 # Convert to 1-based for comparison + if ignored_channels and msg_channel in ignored_channels: + ignored_messages += 1 + continue # Skip modifying this message + # Modify velocity if not ignored + if msg.velocity != new_velocity: + msg.velocity = new_velocity + modified_messages += 1 + + # Generate the output file name by appending '_velfix' before the file extension + base, ext = os.path.splitext(input_path) + output_path = f"{base}_velfix{ext}" + + try: + midi.save(output_path) + if verbose: + print(f"Saved modified MIDI to '{output_path}'.") + print(f"Total messages processed: {total_messages}") + print(f"Total messages modified: {modified_messages}") + if ignored_channels: + print(f"Total messages ignored (channels {sorted(ignored_channels)}): {ignored_messages}") + else: + print(f"Successfully saved modified MIDI to '{output_path}' with all velocities set to {new_velocity}.") + if ignored_channels: + print(f"Ignored velocities on channels: {sorted(ignored_channels)}.") + except IOError: + print(f"Error: Cannot write to output file '{output_path}'. Please check the path and permissions.") + sys.exit(1) + +def validate_velocity(value): + """ + Validates that the velocity is an integer between 0 and 127. + + Args: + value (str): The velocity value as a string. + + Returns: + int: The validated velocity as an integer. + + Raises: + argparse.ArgumentTypeError: If the value is not within the valid range. + """ + try: + ivalue = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"Velocity must be an integer between 0 and 127. '{value}' is invalid.") + if ivalue < 0 or ivalue > 127: + raise argparse.ArgumentTypeError(f"Velocity must be between 0 and 127. '{ivalue}' is out of range.") + return ivalue + +def validate_channels(value): + """ + Validates that the channels are integers between 1 and 16. + + Args: + value (str): Comma-separated channel numbers as a string. + + Returns: + set[int]: A set of validated channel numbers. + + Raises: + argparse.ArgumentTypeError: If any channel is not within the valid range. + """ + try: + channels = set(int(ch.strip()) for ch in value.split(',') if ch.strip() != '') + except ValueError: + raise argparse.ArgumentTypeError(f"Channels must be integers between 1 and 16, separated by commas. '{value}' is invalid.") + for ch in channels: + if ch < 1 or ch > 16: + raise argparse.ArgumentTypeError(f"Channel numbers must be between 1 and 16. '{ch}' is out of range.") + return channels + +def parse_arguments(): + """ + Parses command-line arguments. + + Returns: + argparse.Namespace: The parsed arguments. + """ + parser = argparse.ArgumentParser( + description=( + "Set all velocities in a MIDI file to a specified number, optionally ignoring specified channels, " + "and save with '_velfix' appended to the filename." + ) + ) + parser.add_argument('input_midi', type=str, help='Path to the input MIDI file.') + parser.add_argument('velocity', type=validate_velocity, help='New velocity value (0-127).') + parser.add_argument( + 'ignore_channels', + type=validate_channels, + nargs='?', + default=None, + help='(Optional) Comma-separated list of MIDI channels to ignore (e.g., "4,9"). Channels are 1-16.' + ) + parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output.') + + return parser.parse_args() + +def main(): + args = parse_arguments() + + # Check if input file exists + if not os.path.isfile(args.input_midi): + print(f"Error: The input file '{args.input_midi}' does not exist.") + sys.exit(1) + + set_all_velocities( + args.input_midi, + args.velocity, + ignored_channels=args.ignore_channels, + verbose=args.verbose + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..3a3e288 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.mid +*.midi +.git/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..97343b2 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/core/analyze.py b/server/app/core/analyze.py new file mode 100644 index 0000000..4e35a05 --- /dev/null +++ b/server/app/core/analyze.py @@ -0,0 +1,122 @@ +import mido +from .midi_utils import get_instrument_name, has_musical_messages, collect_tempo_changes + + +def analyze_midi(midi: mido.MidiFile, filename: str = "") -> dict: + analysis = { + "song_title": filename, + "tempo": { + "min_bpm": None, + "max_bpm": None + }, + "pitch_bend": { + "min_semitones": None, + "max_semitones": None + }, + "tracks": [], + "notes": "", + "song_offset": 0 + } + + tempo_changes = collect_tempo_changes(midi) + tempos = [bpm for (_, bpm) in tempo_changes] + analysis["tempo"]["min_bpm"] = min(tempos) + analysis["tempo"]["max_bpm"] = max(tempos) + + musical_track_count = 0 + channel_rpn_state = {channel: {'selected_rpn_msb': None, 'selected_rpn_lsb': None, 'rpn_selected': None} for channel in range(16)} + channel_pitch_bend_range = {channel: 2 for channel in range(16)} + + global_pitch_bends = [] + + for track in midi.tracks: + if not has_musical_messages(track): + continue + + musical_track_count += 1 + track_info = { + "track_name": f"Track {musical_track_count}", + "Channel Assignment": [], + "Pitch Bend Sensitivity": {}, + "Max Note Velocity": "N/A", + "Min Note Velocity": "N/A", + "Uses Program Change": False + } + + program_changes = [] + pitch_bends_semitones = [] + absolute_time = 0 + + for msg in track: + absolute_time += msg.time + if msg.type == 'track_name': + track_info["track_name"] = msg.name + elif hasattr(msg, 'channel'): + channel = msg.channel + if (channel + 1) not in track_info["Channel Assignment"]: + track_info["Channel Assignment"].append(channel + 1) + + if msg.type == 'note_on' and msg.velocity > 0: + velocity = msg.velocity + if track_info["Max Note Velocity"] == "N/A" or velocity > track_info["Max Note Velocity"]: + track_info["Max Note Velocity"] = velocity + if track_info["Min Note Velocity"] == "N/A" or velocity < track_info["Min Note Velocity"]: + track_info["Min Note Velocity"] = velocity + elif msg.type == 'program_change': + track_info["Uses Program Change"] = True + program_changes.append({ + "program_number": msg.program, + "instrument_name": get_instrument_name(msg.program), + "tick": absolute_time + }) + elif msg.type == 'control_change': + if msg.control == 101: + channel_rpn_state[channel]['selected_rpn_msb'] = msg.value + if msg.value != 0: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 100: + channel_rpn_state[channel]['selected_rpn_lsb'] = msg.value + if (channel_rpn_state[channel]['selected_rpn_msb'] == 0 and + channel_rpn_state[channel]['selected_rpn_lsb'] == 0): + channel_rpn_state[channel]['rpn_selected'] = 'pitch_bend_range' + else: + channel_rpn_state[channel]['rpn_selected'] = None + elif msg.control == 6: + if channel_rpn_state[channel].get('rpn_selected') == 'pitch_bend_range': + channel_pitch_bend_range[channel] = msg.value + elif msg.control == 38: + pass + elif msg.type == 'pitchwheel': + current_range = channel_pitch_bend_range[channel] + semitones = (msg.pitch / 8192) * current_range + pitch_bends_semitones.append(semitones) + global_pitch_bends.append(semitones) + + for channel in track_info["Channel Assignment"]: + sensitivity = channel_pitch_bend_range[channel - 1] + track_info["Pitch Bend Sensitivity"][f"Channel {channel}"] = round(sensitivity, 2) + + if pitch_bends_semitones: + track_info["pitch_bend"] = { + "min_semitones": round(min(pitch_bends_semitones), 2), + "max_semitones": round(max(pitch_bends_semitones), 2) + } + else: + track_info["pitch_bend"] = { + "min_semitones": 0.0, + "max_semitones": 0.0 + } + + if program_changes: + track_info["Program Changes"] = program_changes + + analysis["tracks"].append(track_info) + + if global_pitch_bends: + analysis["pitch_bend"]["min_semitones"] = round(min(global_pitch_bends), 2) + analysis["pitch_bend"]["max_semitones"] = round(max(global_pitch_bends), 2) + else: + analysis["pitch_bend"]["min_semitones"] = 0.0 + analysis["pitch_bend"]["max_semitones"] = 0.0 + + return analysis diff --git a/server/app/core/baketempo.py b/server/app/core/baketempo.py new file mode 100644 index 0000000..5e74e2d --- /dev/null +++ b/server/app/core/baketempo.py @@ -0,0 +1,102 @@ +import mido +from mido import MidiFile, MidiTrack, MetaMessage + + +class MIDIMessage: + def __init__(self, message, track, abs_tick, abs_time): + self.message = message + self.track = track + self.abs_tick = abs_tick + self.abs_time = abs_time + self.new_tick = None + + +def compute_absolute_times(mid): + ticks_per_beat = mid.ticks_per_beat + DEFAULT_TEMPO = 500000 + + all_msgs = [] + for track_index, track in enumerate(mid.tracks): + abs_tick = 0 + for msg in track: + abs_tick += msg.time + all_msgs.append(MIDIMessage(msg, track_index, abs_tick, 0.0)) + + all_msgs.sort(key=lambda m: m.abs_tick) + + current_tempo = DEFAULT_TEMPO + abs_time = 0.0 + prev_tick = 0 + + for msg in all_msgs: + delta_ticks = msg.abs_tick - prev_tick + delta_time = mido.tick2second(delta_ticks, ticks_per_beat, current_tempo) + abs_time += delta_time + msg.abs_time = abs_time + + if msg.message.type == 'set_tempo': + current_tempo = msg.message.tempo + prev_tick = msg.abs_tick + + return all_msgs + + +def bake_tempo(all_msgs, ticks_per_beat, constant_tempo): + for msg in all_msgs: + seconds_per_tick = constant_tempo / ticks_per_beat / 1e6 + msg.new_tick = int(round(msg.abs_time / seconds_per_tick)) + return all_msgs + + +def assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat): + new_tracks = [[] for _ in mid.tracks] + + for msg in all_msgs: + if msg.message.type == 'set_tempo': + continue + new_tracks[msg.track].append(msg) + + for track_index, track_msgs in enumerate(new_tracks): + track_msgs.sort(key=lambda m: m.new_tick) + + prev_tick = 0 + new_track = [] + for msg in track_msgs: + delta_tick = msg.new_tick - prev_tick + prev_tick = msg.new_tick + new_msg = msg.message.copy(time=delta_tick) + new_track.append(new_msg) + + if not new_track or new_track[-1].type != 'end_of_track': + new_track.append(MetaMessage('end_of_track', time=0)) + + new_tracks[track_index] = new_track + + return new_tracks + + +def get_initial_tempo(all_msgs, default_tempo=500000): + for msg in all_msgs: + if msg.message.type == 'set_tempo': + return msg.message.tempo + return default_tempo + + +def process(mid: MidiFile) -> MidiFile: + ticks_per_beat = mid.ticks_per_beat + + all_msgs = compute_absolute_times(mid) + initial_tempo = get_initial_tempo(all_msgs) + all_msgs = bake_tempo(all_msgs, ticks_per_beat, initial_tempo) + new_tracks = assign_ticks_to_tracks(all_msgs, mid, ticks_per_beat) + + new_mid = MidiFile(ticks_per_beat=ticks_per_beat) + + tempo_track = MidiTrack() + tempo_track.append(MetaMessage('set_tempo', tempo=initial_tempo, time=0)) + new_mid.tracks.append(tempo_track) + + for track in new_tracks: + new_mid.tracks.append(track) + + return new_mid diff --git a/server/app/core/file_handling.py b/server/app/core/file_handling.py new file mode 100644 index 0000000..607befc --- /dev/null +++ b/server/app/core/file_handling.py @@ -0,0 +1,13 @@ +import io +import mido + + +def load_midi_from_bytes(content: bytes) -> mido.MidiFile: + return mido.MidiFile(file=io.BytesIO(content)) + + +def midi_to_bytes(midi: mido.MidiFile) -> bytes: + buffer = io.BytesIO() + midi.save(file=buffer) + buffer.seek(0) + return buffer.read() diff --git a/server/app/core/midi_utils.py b/server/app/core/midi_utils.py new file mode 100644 index 0000000..b4033de --- /dev/null +++ b/server/app/core/midi_utils.py @@ -0,0 +1,72 @@ +import mido + +GENERAL_MIDI_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "SynthStrings 1", "SynthStrings 2", + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "SynthBrass 1", "SynthBrass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" +] + + +def get_instrument_name(program_number): + if 0 <= program_number < len(GENERAL_MIDI_PROGRAMS): + return GENERAL_MIDI_PROGRAMS[program_number] + return f"Unknown Program ({program_number})" + + +def has_musical_messages(track): + musical_types = { + 'note_on', 'note_off', 'program_change', 'control_change', + 'pitchwheel', 'aftertouch', 'polyphonic_key_pressure' + } + for msg in track: + if msg.type in musical_types: + return True + return False + + +def collect_tempo_changes(midi): + tempo_changes = [] + DEFAULT_TEMPO = 500000 + + for track in midi.tracks: + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'set_tempo': + bpm = mido.tempo2bpm(msg.tempo) + tempo_changes.append((absolute_time, bpm)) + + if not tempo_changes: + tempo_changes.append((0, mido.tempo2bpm(DEFAULT_TEMPO))) + + tempo_changes.sort(key=lambda x: x[0]) + return tempo_changes diff --git a/server/app/core/monofy.py b/server/app/core/monofy.py new file mode 100644 index 0000000..924475c --- /dev/null +++ b/server/app/core/monofy.py @@ -0,0 +1,122 @@ +from mido import MidiFile, MidiTrack, Message, MetaMessage +from collections import defaultdict + + +class Note: + def __init__(self, note, start, end, channel, velocity_on, velocity_off): + self.note = note + self.start = start + self.end = end + self.channel = channel + self.velocity_on = velocity_on + self.velocity_off = velocity_off + self.voice = None + + +def get_notes_and_events(track): + notes = [] + ongoing_notes = {} + non_note_events = [] + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'note_on' and msg.velocity > 0: + key = (msg.note, msg.channel) + ongoing_notes[key] = (absolute_time, msg.velocity) + elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): + key = (msg.note, msg.channel) + if key in ongoing_notes: + start_time, velocity_on = ongoing_notes.pop(key) + notes.append(Note(msg.note, start_time, absolute_time, msg.channel, velocity_on, msg.velocity)) + else: + non_note_events.append((absolute_time, msg.copy(time=0))) + else: + non_note_events.append((absolute_time, msg.copy(time=0))) + return notes, non_note_events + + +def assign_voices(notes): + notes.sort(key=lambda x: (x.start, x.note)) + voices = [] + for note in notes: + available = [i for i, end in enumerate(voices) if end <= note.start] + if available: + voice = min(available) + voices[voice] = note.end + else: + voice = len(voices) + voices.append(note.end) + note.voice = voice + return notes, len(voices) + + +def create_track_name(original_name, voice): + if voice == 0: + return original_name + return f"{original_name}_{voice}" + + +def merge_events(note_events, non_note_events, voice): + events = [] + + for abs_time, msg in non_note_events: + events.append((abs_time, msg)) + + for note in note_events: + if note.voice != voice: + continue + events.append((note.start, Message('note_on', note=note.note, velocity=note.velocity_on, channel=note.channel, time=0))) + events.append((note.end, Message('note_off', note=note.note, velocity=note.velocity_off, channel=note.channel, time=0))) + + events.sort(key=lambda x: x[0]) + + merged_msgs = [] + prev_time = 0 + for abs_time, msg in events: + delta = abs_time - prev_time + msg.time = delta + merged_msgs.append(msg) + prev_time = abs_time + + return merged_msgs + + +def process_track(track, original_track_index): + track_name = f"Track{original_track_index}" + for msg in track: + if msg.type == 'track_name': + track_name = msg.name + break + + notes, non_note_events = get_notes_and_events(track) + if not notes: + return [track] + + assigned_notes, num_voices = assign_voices(notes) + + voice_to_notes = defaultdict(list) + for note in assigned_notes: + voice_to_notes[note.voice].append(note) + + new_tracks = [] + for voice in range(num_voices): + new_track = MidiTrack() + new_track.append(MetaMessage('track_name', name=create_track_name(track_name, voice), time=0)) + merged_msgs = merge_events(voice_to_notes[voice], non_note_events, voice) + new_track.extend(merged_msgs) + new_tracks.append(new_track) + return new_tracks + + +def process(mid: MidiFile, tracks: set[int] | None = None) -> MidiFile: + new_mid = MidiFile() + new_mid.ticks_per_beat = mid.ticks_per_beat + + for i, track in enumerate(mid.tracks): + if tracks is not None and i not in tracks: + new_mid.tracks.append(track) + else: + new_tracks = process_track(track, i) + new_mid.tracks.extend(new_tracks) + + return new_mid diff --git a/server/app/core/reduncheck.py b/server/app/core/reduncheck.py new file mode 100644 index 0000000..17eeaa3 --- /dev/null +++ b/server/app/core/reduncheck.py @@ -0,0 +1,40 @@ +import mido + + +def process(midi: mido.MidiFile, tracks: set[int] | None = None) -> mido.MidiFile: + cleaned_midi = mido.MidiFile() + cleaned_midi.ticks_per_beat = midi.ticks_per_beat + + for i, track in enumerate(midi.tracks): + if tracks is not None and i not in tracks: + cleaned_midi.tracks.append(track) + continue + + cleaned_track = mido.MidiTrack() + last_state = {} + last_msg = None + + for msg in track: + + if last_msg is not None and msg == last_msg: + continue + + if msg.type in ['control_change', 'program_change', 'pitchwheel', 'aftertouch', 'channel_pressure']: + channel = msg.channel + key = f"{msg.type}_{channel}" + if key in last_state and last_state[key] == msg: + continue + last_state[key] = msg + cleaned_track.append(msg) + elif msg.type == 'note_off': + cleaned_track.append(msg) + elif msg.type == 'note_on' and msg.velocity == 0: + cleaned_track.append(msg) + else: + cleaned_track.append(msg) + + last_msg = msg + + cleaned_midi.tracks.append(cleaned_track) + + return cleaned_midi diff --git a/server/app/core/track_detail.py b/server/app/core/track_detail.py new file mode 100644 index 0000000..1bf2ec0 --- /dev/null +++ b/server/app/core/track_detail.py @@ -0,0 +1,79 @@ +import mido +from .midi_utils import has_musical_messages + +CC_NAMES = { + 0: "Bank Select", 1: "Modulation", 2: "Breath Controller", 4: "Foot Controller", + 5: "Portamento Time", 7: "Volume", 10: "Pan", 11: "Expression", + 64: "Sustain Pedal", 65: "Portamento", 66: "Sostenuto", 67: "Soft Pedal", + 71: "Resonance", 72: "Release Time", 73: "Attack Time", 74: "Cutoff Frequency", + 91: "Reverb", 93: "Chorus", 94: "Detune", +} + + +def get_track_detail(midi: mido.MidiFile, track_index: int) -> dict | None: + """Extract detailed time-series data for a specific musical track.""" + musical_idx = 0 + target_track = None + for track in midi.tracks: + if not has_musical_messages(track): + continue + if musical_idx == track_index: + target_track = track + break + musical_idx += 1 + + if target_track is None: + return None + + ticks_per_beat = midi.ticks_per_beat + + track_name = f"Track {track_index + 1}" + control_changes = {} # cc_number -> list of [tick, value] + pitch_bend = [] # list of [tick, value] + velocities = [] # list of [tick, velocity] + ongoing_notes = {} # (note, channel) -> (start_tick, velocity) + notes = [] # list of [note, start, end, velocity] + + absolute_tick = 0 + + for msg in target_track: + absolute_tick += msg.time + + if msg.type == 'track_name': + track_name = msg.name + elif msg.type == 'note_on' and msg.velocity > 0: + velocities.append([absolute_tick, msg.velocity]) + key = (msg.note, msg.channel) + ongoing_notes[key] = (absolute_tick, msg.velocity) + elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0): + key = (msg.note, msg.channel) + if key in ongoing_notes: + start_tick, vel = ongoing_notes.pop(key) + notes.append([msg.note, start_tick, absolute_tick, vel]) + elif msg.type == 'control_change': + cc = msg.control + if cc not in control_changes: + control_changes[cc] = { + "name": CC_NAMES.get(cc, f"CC {cc}"), + "data": [] + } + control_changes[cc]["data"].append([absolute_tick, msg.value]) + elif msg.type == 'pitchwheel': + pitch_bend.append([absolute_tick, msg.pitch]) + + # Convert cc keys to strings for JSON + cc_out = {} + for cc_num, cc_data in sorted(control_changes.items()): + cc_out[str(cc_num)] = cc_data + + total_ticks = absolute_tick + + return { + "track_name": track_name, + "ticks_per_beat": ticks_per_beat, + "total_ticks": total_ticks, + "notes": notes, + "control_changes": cc_out, + "pitch_bend": pitch_bend, + "velocities": velocities + } diff --git a/server/app/core/type0.py b/server/app/core/type0.py new file mode 100644 index 0000000..489444d --- /dev/null +++ b/server/app/core/type0.py @@ -0,0 +1,12 @@ +from mido import MidiFile, MidiTrack, merge_tracks + + +def process(midi: MidiFile) -> MidiFile: + merged_track = merge_tracks(midi.tracks) + + type0_midi = MidiFile() + type0_midi.type = 0 + type0_midi.ticks_per_beat = midi.ticks_per_beat + type0_midi.tracks.append(MidiTrack(merged_track)) + + return type0_midi diff --git a/server/app/core/velfix.py b/server/app/core/velfix.py new file mode 100644 index 0000000..51b83c3 --- /dev/null +++ b/server/app/core/velfix.py @@ -0,0 +1,30 @@ +import mido + + +def process(midi: mido.MidiFile, vel_min: int, vel_max: int, tracks: set[int] | None = None) -> mido.MidiFile: + # Collect current velocity range from note_on messages in selected tracks + cur_min = 127 + cur_max = 0 + for i, track in enumerate(midi.tracks): + if tracks is not None and i not in tracks: + continue + for msg in track: + if msg.type == 'note_on' and msg.velocity > 0: + if msg.velocity < cur_min: + cur_min = msg.velocity + if msg.velocity > cur_max: + cur_max = msg.velocity + + cur_range = cur_max - cur_min if cur_max > cur_min else 1 + + # Remap velocities to new range + for i, track in enumerate(midi.tracks): + if tracks is not None and i not in tracks: + continue + for msg in track: + if msg.type == 'note_on' and msg.velocity > 0: + ratio = (msg.velocity - cur_min) / cur_range + msg.velocity = round(vel_min + ratio * (vel_max - vel_min)) + msg.velocity = max(1, min(127, msg.velocity)) + + return midi diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..3d1ff4d --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,17 @@ +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from .routers import session + +app = FastAPI(title="MIDI Tools", version="1.0.0") + +app.include_router(session.router) + +static_dir = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + +@app.get("/") +async def root(): + return FileResponse(str(static_dir / "index.html")) diff --git a/server/app/routers/__init__.py b/server/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/routers/session.py b/server/app/routers/session.py new file mode 100644 index 0000000..ffcd636 --- /dev/null +++ b/server/app/routers/session.py @@ -0,0 +1,432 @@ +import os +import uuid +from dataclasses import dataclass, field +from fastapi import APIRouter, UploadFile, File, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel +import mido +from ..core.file_handling import load_midi_from_bytes, midi_to_bytes +from ..core.analyze import analyze_midi +from ..core.track_detail import get_track_detail +from ..core.midi_utils import has_musical_messages, get_instrument_name +from ..core import baketempo as baketempo_core +from ..core import monofy as monofy_core +from ..core import reduncheck as reduncheck_core +from ..core import velfix as velfix_core +from ..core import type0 as type0_core + +router = APIRouter(prefix="/api/session") + +# In-memory session store +sessions: dict[str, "Session"] = {} + + +@dataclass +class Session: + midi_bytes: bytes + original_name: str + undo_stack: list[bytes] = field(default_factory=list) + history: list[str] = field(default_factory=list) + + +class ApplyRequest(BaseModel): + tool: str + channels: list[int] | None = None + vel_min: int | None = None + vel_max: int | None = None + tracks: list[int] | None = None + + +class TrackEditRequest(BaseModel): + channel: int | None = None + program: int | None = None + + +class MergeRequest(BaseModel): + tracks: list[int] + + +def _musical_to_raw_indices(midi, musical_indices: set[int]) -> set[int]: + """Convert 0-based musical track indices to raw MIDI file track indices.""" + raw = set() + musical_idx = 0 + for i, track in enumerate(midi.tracks): + if not has_musical_messages(track): + continue + if musical_idx in musical_indices: + raw.add(i) + musical_idx += 1 + return raw + + +def _find_musical_track(midi, track_index: int): + """Find a musical track by its 0-based musical index. Returns (raw_index, track) or (None, None).""" + musical_idx = 0 + for i, track in enumerate(midi.tracks): + if not has_musical_messages(track): + continue + if musical_idx == track_index: + return i, track + musical_idx += 1 + return None, None + + +def _analyze_session(session: Session) -> dict: + midi = load_midi_from_bytes(session.midi_bytes) + filename = os.path.splitext(session.original_name)[0] + return analyze_midi(midi, filename) + + +@router.post("/upload") +async def upload(file: UploadFile = File(...)): + if not file.filename.lower().endswith(('.mid', '.midi')): + raise HTTPException(400, "File must be a .mid or .midi file") + + content = await file.read() + try: + midi = load_midi_from_bytes(content) + except Exception as e: + raise HTTPException(400, f"Invalid MIDI file: {e}") + + session_id = str(uuid.uuid4()) + session = Session(midi_bytes=content, original_name=file.filename) + sessions[session_id] = session + + filename = os.path.splitext(file.filename)[0] + analysis = analyze_midi(midi, filename) + + return { + "session_id": session_id, + "analysis": analysis, + "history": [] + } + + +@router.post("/{session_id}/apply") +async def apply_tool(session_id: str, request: ApplyRequest): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + channels = set(request.channels) if request.channels else None + + # Push current state to undo stack + session.undo_stack.append(session.midi_bytes) + + try: + midi = load_midi_from_bytes(session.midi_bytes) + except Exception as e: + session.undo_stack.pop() + raise HTTPException(500, f"Failed to load MIDI: {e}") + + # Build history label + label = _tool_label(request) + + try: + if request.tool == "baketempo": + result = baketempo_core.process(midi) + elif request.tool == "monofy": + raw_tracks = None + if request.tracks is not None: + raw_tracks = _musical_to_raw_indices(midi, set(request.tracks)) + result = monofy_core.process(midi, raw_tracks) + elif request.tool == "reduncheck": + raw_tracks = None + if request.tracks is not None: + raw_tracks = _musical_to_raw_indices(midi, set(request.tracks)) + result = reduncheck_core.process(midi, raw_tracks) + elif request.tool == "velfix": + if request.vel_min is None or request.vel_max is None: + session.undo_stack.pop() + raise HTTPException(400, "vel_min and vel_max are required for velfix") + if not (0 <= request.vel_min <= 127) or not (0 <= request.vel_max <= 127): + session.undo_stack.pop() + raise HTTPException(400, "velocities must be 0-127") + if request.vel_min > request.vel_max: + session.undo_stack.pop() + raise HTTPException(400, "vel_min must be <= vel_max") + raw_tracks = None + if request.tracks is not None: + raw_tracks = _musical_to_raw_indices(midi, set(request.tracks)) + result = velfix_core.process(midi, request.vel_min, request.vel_max, raw_tracks) + elif request.tool == "type0": + result = type0_core.process(midi) + else: + session.undo_stack.pop() + raise HTTPException(400, f"Unknown tool: {request.tool}") + except HTTPException: + raise + except Exception as e: + session.undo_stack.pop() + raise HTTPException(500, f"Processing error: {e}") + + session.midi_bytes = midi_to_bytes(result) + session.history.append(label) + + analysis = _analyze_session(session) + return { + "analysis": analysis, + "history": session.history + } + + +@router.post("/{session_id}/undo") +async def undo(session_id: str): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + if not session.undo_stack: + raise HTTPException(400, "Nothing to undo") + + session.midi_bytes = session.undo_stack.pop() + session.history.pop() + + analysis = _analyze_session(session) + return { + "analysis": analysis, + "history": session.history + } + + +@router.get("/{session_id}/download") +async def download(session_id: str): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + base = os.path.splitext(session.original_name)[0] + filename = f"{base}_edited.mid" if session.history else session.original_name + + return Response( + content=session.midi_bytes, + media_type="audio/midi", + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/{session_id}/track/{track_index}") +async def track_detail(session_id: str, track_index: int): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + midi = load_midi_from_bytes(session.midi_bytes) + detail = get_track_detail(midi, track_index) + if detail is None: + raise HTTPException(404, "Track not found") + + return detail + + +@router.post("/{session_id}/track/{track_index}/edit") +async def edit_track(session_id: str, track_index: int, request: TrackEditRequest): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + if request.channel is not None and not (1 <= request.channel <= 16): + raise HTTPException(400, "Channel must be 1-16") + if request.program is not None and not (0 <= request.program <= 127): + raise HTTPException(400, "Program must be 0-127") + + session.undo_stack.append(session.midi_bytes) + + try: + midi = load_midi_from_bytes(session.midi_bytes) + except Exception as e: + session.undo_stack.pop() + raise HTTPException(500, f"Failed to load MIDI: {e}") + + raw_idx, target_track = _find_musical_track(midi, track_index) + if target_track is None: + session.undo_stack.pop() + raise HTTPException(404, "Track not found") + + # Get track name for history label + track_name = f"Track {track_index + 1}" + for msg in target_track: + if msg.type == 'track_name': + track_name = msg.name + break + + label_parts = [] + + if request.channel is not None: + new_channel = request.channel - 1 + old_channels = set() + for msg in target_track: + if hasattr(msg, 'channel'): + old_channels.add(msg.channel + 1) + old_ch_str = ",".join(str(c) for c in sorted(old_channels)) if old_channels else "?" + for msg in target_track: + if hasattr(msg, 'channel'): + msg.channel = new_channel + label_parts.append(f"CH {old_ch_str} \u2192 {request.channel}") + + if request.program is not None: + instrument_name = get_instrument_name(request.program) + found_pc = False + for msg in target_track: + if msg.type == 'program_change': + msg.program = request.program + found_pc = True + break + if not found_pc: + ch = request.channel - 1 if request.channel else 0 + for msg in target_track: + if hasattr(msg, 'channel'): + ch = msg.channel + break + pc_msg = mido.Message('program_change', program=request.program, channel=ch, time=0) + insert_idx = 0 + for j, msg in enumerate(target_track): + if msg.is_meta: + insert_idx = j + 1 + else: + break + target_track.insert(insert_idx, pc_msg) + label_parts.append(f"Instrument \u2192 {instrument_name}") + + label = f"{track_name}: {', '.join(label_parts)}" + session.midi_bytes = midi_to_bytes(midi) + session.history.append(label) + + analysis = _analyze_session(session) + return { + "analysis": analysis, + "history": session.history + } + + +@router.post("/{session_id}/track/{track_index}/delete") +async def delete_track(session_id: str, track_index: int): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + session.undo_stack.append(session.midi_bytes) + + try: + midi = load_midi_from_bytes(session.midi_bytes) + except Exception as e: + session.undo_stack.pop() + raise HTTPException(500, f"Failed to load MIDI: {e}") + + raw_idx, target_track = _find_musical_track(midi, track_index) + if target_track is None: + session.undo_stack.pop() + raise HTTPException(404, "Track not found") + + track_name = f"Track {track_index + 1}" + for msg in target_track: + if msg.type == 'track_name': + track_name = msg.name + break + + midi.tracks.pop(raw_idx) + + label = f"Delete {track_name}" + session.midi_bytes = midi_to_bytes(midi) + session.history.append(label) + + analysis = _analyze_session(session) + return { + "analysis": analysis, + "history": session.history + } + + +@router.post("/{session_id}/merge") +async def merge_tracks(session_id: str, request: MergeRequest): + session = sessions.get(session_id) + if not session: + raise HTTPException(404, "Session not found") + + if len(request.tracks) < 2: + raise HTTPException(400, "At least 2 tracks required for merge") + + session.undo_stack.append(session.midi_bytes) + + try: + midi = load_midi_from_bytes(session.midi_bytes) + except Exception as e: + session.undo_stack.pop() + raise HTTPException(500, f"Failed to load MIDI: {e}") + + # Collect raw indices and track references + raw_indices = [] + first_track_name = None + for musical_idx in sorted(request.tracks): + raw_idx, track = _find_musical_track(midi, musical_idx) + if track is None: + session.undo_stack.pop() + raise HTTPException(404, f"Track {musical_idx} not found") + raw_indices.append(raw_idx) + if first_track_name is None: + for msg in track: + if msg.type == 'track_name': + first_track_name = msg.name + break + + if first_track_name is None: + first_track_name = "Merged" + + # Collect all events with absolute timing + all_events = [] + for raw_idx in raw_indices: + track = midi.tracks[raw_idx] + absolute_time = 0 + for msg in track: + absolute_time += msg.time + if msg.type == 'track_name': + continue # Skip track names from source tracks + all_events.append((absolute_time, msg.copy(time=0))) + + # Sort by absolute time and convert to delta + all_events.sort(key=lambda x: x[0]) + merged_track = mido.MidiTrack() + merged_track.append(mido.MetaMessage('track_name', name=first_track_name, time=0)) + prev_time = 0 + for abs_time, msg in all_events: + msg.time = abs_time - prev_time + merged_track.append(msg) + prev_time = abs_time + + # Remove old tracks in reverse order, then insert merged + for raw_idx in sorted(raw_indices, reverse=True): + midi.tracks.pop(raw_idx) + insert_pos = min(raw_indices) + midi.tracks.insert(insert_pos, merged_track) + + track_nums = ", ".join(str(t + 1) for t in sorted(request.tracks)) + label = f"Merge Tracks {track_nums}" + session.midi_bytes = midi_to_bytes(midi) + session.history.append(label) + + analysis = _analyze_session(session) + return { + "analysis": analysis, + "history": session.history + } + + +def _tool_label(request: ApplyRequest) -> str: + names = { + "baketempo": "Bake Tempo", + "monofy": "Monofy", + "reduncheck": "Remove Redundancy", + "velfix": "Velocity Fix", + "type0": "Convert to Type 0" + } + label = names.get(request.tool, request.tool) + parts = [] + if request.channels: + parts.append(f"CH {','.join(str(c) for c in sorted(request.channels))}") + if request.tracks is not None: + parts.append(f"Tracks {','.join(str(t + 1) for t in sorted(request.tracks))}") + if request.vel_min is not None and request.vel_max is not None: + parts.append(f"vel={request.vel_min}-{request.vel_max}") + if parts: + label += f" ({'; '.join(parts)})" + return label diff --git a/server/app/static/app.js b/server/app/static/app.js new file mode 100644 index 0000000..01026b6 --- /dev/null +++ b/server/app/static/app.js @@ -0,0 +1,897 @@ +const GENERAL_MIDI_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag Pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" +]; + +const TOOLS = { + baketempo: { + label: "Bake Tempo", + description: "Removes all tempo change messages and bakes the playback speed into absolute event timing. The output file uses a single constant tempo while preserving the original playback speed.", + channelSelect: false, + params: [] + }, + monofy: { + label: "Monofy (Split Polyphonic)", + description: "Splits polyphonic tracks into separate monophonic tracks while preserving channel assignments. Useful for splitting chords across multiple Tesla Coil outputs.", + channelSelect: false, + trackSelect: true, + params: [] + }, + reduncheck: { + label: "Remove Redundancy", + description: "Detects and removes redundant MIDI data such as consecutive duplicate messages and repeated control change values.", + channelSelect: false, + trackSelect: true, + params: [] + }, + velfix: { + label: "Velocity Fix", + description: "Remaps note velocities into a target range. The existing velocity spread is scaled to fit between the min and max values, preserving relative dynamics.", + channelSelect: false, + trackSelect: true, + params: ["velocity"] + }, + type0: { + label: "Convert to Type 0", + description: "Merges all tracks into a single track, converting the file to MIDI Type 0 format.", + channelSelect: false, + params: [] + } +}; + +// DOM elements +const dropZone = document.getElementById("drop-zone"); +const fileInput = document.getElementById("file-input"); +const workspace = document.getElementById("workspace"); +const fileName = document.getElementById("file-name"); +const clearBtn = document.getElementById("clear-file"); +const downloadBtn = document.getElementById("download-btn"); +const toolSelect = document.getElementById("tool"); +const toolDescription = document.getElementById("tool-description"); +const paramsDiv = document.getElementById("params"); +const paramVelocity = document.getElementById("param-velocity"); +const processBtn = document.getElementById("process-btn"); +const statusDiv = document.getElementById("status"); +const analysisGrid = document.getElementById("analysis-grid"); +const trackList = document.getElementById("track-list"); +const historySection = document.getElementById("history-section"); +const historyList = document.getElementById("history-list"); +const undoBtn = document.getElementById("undo-btn"); + +const trackDetail = document.getElementById("track-detail"); +const detailTrackName = document.getElementById("detail-track-name"); +const graphContainer = document.getElementById("graph-container"); +const closeDetailBtn = document.getElementById("close-detail"); +const midiPlayer = document.getElementById("midi-player"); +const trackSection = document.getElementById("track-section"); +const trackCheckboxes = document.getElementById("track-checkboxes"); +const trackToggle = document.getElementById("track-toggle"); +const trackChannelSelect = document.getElementById("track-channel"); +const trackInstrumentSelect = document.getElementById("track-instrument"); +const trackEditSaveBtn = document.getElementById("track-edit-save"); +const deleteTrackBtn = document.getElementById("delete-track-btn"); +const mergeBtn = document.getElementById("merge-btn"); + +// Populate channel dropdown (1-16) +for (let i = 1; i <= 16; i++) { + const opt = document.createElement("option"); + opt.value = i; + opt.textContent = i; + trackChannelSelect.appendChild(opt); +} + +// Populate instrument dropdown +GENERAL_MIDI_PROGRAMS.forEach((name, idx) => { + const opt = document.createElement("option"); + opt.value = idx; + opt.textContent = `${idx}: ${name}`; + trackInstrumentSelect.appendChild(opt); +}); + +// State +let sessionId = null; +let selectedTrackIndex = null; +let currentBlobUrl = null; +let currentAnalysis = null; + +// File upload +dropZone.addEventListener("click", () => fileInput.click()); +fileInput.addEventListener("change", (e) => { + if (e.target.files.length) uploadFile(e.target.files[0]); +}); + +dropZone.addEventListener("dragover", (e) => { + e.preventDefault(); + dropZone.classList.add("dragover"); +}); + +dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove("dragover"); +}); + +dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + dropZone.classList.remove("dragover"); + if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); +}); + +clearBtn.addEventListener("click", () => { + sessionId = null; + fileInput.value = ""; + workspace.classList.add("hidden"); + dropZone.classList.remove("hidden"); + statusDiv.classList.add("hidden"); + updatePlayerSource(); +}); + +async function uploadFile(file) { + const name = file.name.toLowerCase(); + if (!name.endsWith(".mid") && !name.endsWith(".midi")) { + showStatus("Please select a .mid or .midi file", "error"); + return; + } + + fileName.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`; + dropZone.classList.add("hidden"); + workspace.classList.remove("hidden"); + processBtn.disabled = true; + analysisGrid.innerHTML = '
Status
Uploading...
'; + trackList.innerHTML = ""; + historySection.classList.add("hidden"); + statusDiv.classList.add("hidden"); + + const formData = new FormData(); + formData.append("file", file); + + try { + const resp = await fetch("/api/session/upload", { + method: "POST", + body: formData + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + sessionId = data.session_id; + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + processBtn.disabled = false; + } catch (e) { + showStatus(`Upload failed: ${e.message}`, "error"); + } +} + +// Player source management +async function updatePlayerSource() { + if (midiPlayer.playing) { + midiPlayer.stop(); + } + if (currentBlobUrl) { + URL.revokeObjectURL(currentBlobUrl); + currentBlobUrl = null; + } + if (!sessionId) { + midiPlayer.src = ""; + return; + } + try { + const resp = await fetch(`/api/session/${sessionId}/download`); + if (!resp.ok) return; + const blob = await resp.blob(); + currentBlobUrl = URL.createObjectURL(blob); + midiPlayer.src = currentBlobUrl; + } catch (e) { + console.warn("Failed to load MIDI for playback:", e); + } +} + +// Download +downloadBtn.addEventListener("click", async () => { + if (!sessionId) return; + + try { + const resp = await fetch(`/api/session/${sessionId}/download`); + if (!resp.ok) { + showStatus("Download failed", "error"); + return; + } + const blob = await resp.blob(); + const disposition = resp.headers.get("Content-Disposition"); + let downloadName = "output.mid"; + if (disposition) { + const match = disposition.match(/filename="?(.+?)"?$/); + if (match) downloadName = match[1]; + } + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = downloadName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + showStatus(`Download failed: ${e.message}`, "error"); + } +}); + +// Analysis rendering +function renderAnalysis(data) { + currentAnalysis = data; + analysisGrid.innerHTML = ""; + + addMetric("Song Title", data.song_title || "Unknown"); + + if (data.tempo.min_bpm === data.tempo.max_bpm) { + addMetric("Tempo", `${Math.round(data.tempo.min_bpm)} BPM`); + } else { + addMetric("Tempo", `${Math.round(data.tempo.min_bpm)} - ${Math.round(data.tempo.max_bpm)} BPM`); + } + + addMetric("Tracks", `${data.tracks.length}`); + + const allChannels = new Set(); + data.tracks.forEach(t => t["Channel Assignment"].forEach(ch => allChannels.add(ch))); + const channelList = [...allChannels].sort((a, b) => a - b); + addMetric("Channels", channelList.length > 0 ? channelList.join(", ") : "None"); + + if (data.pitch_bend.min_semitones !== 0 || data.pitch_bend.max_semitones !== 0) { + addMetric("Pitch Bend", `${data.pitch_bend.min_semitones} to ${data.pitch_bend.max_semitones} st`); + } + + trackList.innerHTML = ""; + trackDetail.classList.add("hidden"); + selectedTrackIndex = null; + + data.tracks.forEach((track, idx) => { + const card = document.createElement("div"); + card.className = "track-card"; + const displayName = `${idx + 1}. ${track.track_name}`; + card.addEventListener("click", (e) => { + if (e.target.type === "checkbox") return; // Don't toggle detail when clicking merge checkbox + selectTrack(idx, displayName, card); + }); + + // Merge checkbox + if (data.tracks.length > 1) { + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.className = "merge-checkbox"; + cb.dataset.trackIdx = idx; + cb.addEventListener("change", updateMergeButton); + card.appendChild(cb); + } + + const header = document.createElement("div"); + header.className = "track-header"; + header.textContent = displayName; + + track["Channel Assignment"].forEach(ch => { + const badge = document.createElement("span"); + badge.className = "channel-badge"; + badge.textContent = `CH ${ch}`; + header.appendChild(badge); + }); + + card.appendChild(header); + + if (track["Max Note Velocity"] !== "N/A") { + if (track["Min Note Velocity"] === track["Max Note Velocity"]) { + addDetail(card, "Velocity", `${track["Min Note Velocity"]}`); + } else { + addDetail(card, "Velocity", `${track["Min Note Velocity"]} - ${track["Max Note Velocity"]}`); + } + } + + if (track["Program Changes"] && track["Program Changes"].length > 0) { + const instruments = [...new Set(track["Program Changes"].map(pc => pc.instrument_name))]; + addDetail(card, "Instrument", instruments.join(", ")); + } + + if (track.pitch_bend && (track.pitch_bend.min_semitones !== 0 || track.pitch_bend.max_semitones !== 0)) { + addDetail(card, "Pitch Bend", `${track.pitch_bend.min_semitones} to ${track.pitch_bend.max_semitones} st`); + } + + const sensitivities = Object.entries(track["Pitch Bend Sensitivity"] || {}); + if (sensitivities.length > 0) { + const vals = sensitivities.map(([ch, val]) => `${ch}: \u00B1${val}`).join(", "); + addDetail(card, "PB Sensitivity", vals); + } + + trackList.appendChild(card); + }); + + buildTrackCheckboxes(); + updateTool(); +} + +function addMetric(label, value) { + const card = document.createElement("div"); + card.className = "metric-card"; + card.innerHTML = `
${label}
${value}
`; + analysisGrid.appendChild(card); +} + +function addDetail(parent, label, value) { + const row = document.createElement("div"); + row.className = "detail-row"; + row.innerHTML = `${label}${value}`; + parent.appendChild(row); +} + +// Track checkboxes +function buildTrackCheckboxes() { + trackCheckboxes.innerHTML = ""; + if (!currentAnalysis) return; + currentAnalysis.tracks.forEach((track, idx) => { + const label = document.createElement("label"); + label.innerHTML = ` ${idx + 1}. ${track.track_name}`; + trackCheckboxes.appendChild(label); + }); +} + +function getSelectedTracks() { + const checked = trackCheckboxes.querySelectorAll("input:checked"); + return [...checked].map(cb => parseInt(cb.value)); +} + +trackToggle.addEventListener("click", () => { + const boxes = trackCheckboxes.querySelectorAll("input"); + const allChecked = [...boxes].every(cb => cb.checked); + boxes.forEach(cb => cb.checked = !allChecked); + trackToggle.textContent = allChecked ? "Select All" : "Deselect All"; +}); + +// Tool selection +toolSelect.addEventListener("change", updateTool); +updateTool(); + +function updateTool() { + const tool = TOOLS[toolSelect.value]; + toolDescription.textContent = tool.description; + + if (tool.trackSelect && currentAnalysis && currentAnalysis.tracks.length > 0) { + trackSection.classList.remove("hidden"); + } else { + trackSection.classList.add("hidden"); + } + + if (tool.params.includes("velocity")) { + paramsDiv.classList.remove("hidden"); + paramVelocity.classList.remove("hidden"); + } else { + paramsDiv.classList.add("hidden"); + paramVelocity.classList.add("hidden"); + } +} + +// Apply tool +processBtn.addEventListener("click", async () => { + if (!sessionId) return; + + const toolKey = toolSelect.value; + const tool = TOOLS[toolKey]; + + const body = { tool: toolKey }; + + if (tool.trackSelect && currentAnalysis && currentAnalysis.tracks.length > 0) { + body.tracks = getSelectedTracks(); + if (body.tracks.length === 0) { + showStatus("Select at least one track", "error"); + return; + } + } + + if (tool.params.includes("velocity")) { + body.vel_min = parseInt(document.getElementById("vel-min").value); + body.vel_max = parseInt(document.getElementById("vel-max").value); + if (isNaN(body.vel_min) || isNaN(body.vel_max) || body.vel_min < 0 || body.vel_max > 127) { + showStatus("Velocities must be 0-127", "error"); + return; + } + if (body.vel_min > body.vel_max) { + showStatus("Min velocity must be <= max", "error"); + return; + } + } + + processBtn.disabled = true; + showStatus("Processing...", "loading"); + + try { + const resp = await fetch(`/api/session/${sessionId}/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + showStatus("Applied successfully", "success"); + } catch (e) { + showStatus(`Error: ${e.message}`, "error"); + } finally { + processBtn.disabled = false; + } +}); + +// Undo +undoBtn.addEventListener("click", async () => { + if (!sessionId) return; + + undoBtn.disabled = true; + showStatus("Undoing...", "loading"); + + try { + const resp = await fetch(`/api/session/${sessionId}/undo`, { + method: "POST" + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + showStatus("Undone", "success"); + } catch (e) { + showStatus(`Error: ${e.message}`, "error"); + } finally { + undoBtn.disabled = false; + } +}); + +// History +function renderHistory(history) { + if (history.length === 0) { + historySection.classList.add("hidden"); + return; + } + + historySection.classList.remove("hidden"); + historyList.innerHTML = ""; + history.forEach(entry => { + const li = document.createElement("li"); + li.textContent = entry; + historyList.appendChild(li); + }); + + undoBtn.disabled = false; +} + +// Track detail +closeDetailBtn.addEventListener("click", () => { + trackDetail.classList.add("hidden"); + document.querySelectorAll(".track-card.selected").forEach(c => c.classList.remove("selected")); + selectedTrackIndex = null; +}); + +trackEditSaveBtn.addEventListener("click", async () => { + if (!sessionId || selectedTrackIndex === null) return; + + const body = { + channel: parseInt(trackChannelSelect.value), + program: parseInt(trackInstrumentSelect.value) + }; + + trackEditSaveBtn.disabled = true; + trackEditSaveBtn.textContent = "Saving..."; + + try { + const resp = await fetch(`/api/session/${sessionId}/track/${selectedTrackIndex}/edit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + const reopenIndex = selectedTrackIndex; + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + showStatus("Track updated", "success"); + + // Re-open the track detail panel + const trackCards = trackList.querySelectorAll(".track-card"); + if (trackCards[reopenIndex] && currentAnalysis.tracks[reopenIndex]) { + const trackName = `${reopenIndex + 1}. ${currentAnalysis.tracks[reopenIndex].track_name}`; + selectTrack(reopenIndex, trackName, trackCards[reopenIndex]); + } + } catch (e) { + showStatus(`Error: ${e.message}`, "error"); + } finally { + trackEditSaveBtn.disabled = false; + trackEditSaveBtn.textContent = "Save"; + } +}); + +// Merge button state +function updateMergeButton() { + const checked = trackList.querySelectorAll(".merge-checkbox:checked"); + mergeBtn.disabled = checked.length < 2; +} + +mergeBtn.addEventListener("click", async () => { + if (!sessionId) return; + const checked = trackList.querySelectorAll(".merge-checkbox:checked"); + const indices = [...checked].map(cb => parseInt(cb.dataset.trackIdx)); + if (indices.length < 2) return; + + if (!confirm(`Merge ${indices.length} tracks into one?`)) return; + + mergeBtn.disabled = true; + showStatus("Merging...", "loading"); + + try { + const resp = await fetch(`/api/session/${sessionId}/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tracks: indices }) + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + showStatus("Tracks merged", "success"); + } catch (e) { + showStatus(`Error: ${e.message}`, "error"); + } finally { + mergeBtn.disabled = true; + } +}); + +// Delete track +deleteTrackBtn.addEventListener("click", async () => { + if (!sessionId || selectedTrackIndex === null) return; + + const trackName = detailTrackName.textContent; + if (!confirm(`Delete "${trackName}"?`)) return; + + deleteTrackBtn.disabled = true; + + try { + const resp = await fetch(`/api/session/${sessionId}/track/${selectedTrackIndex}/delete`, { + method: "POST" + }); + + if (!resp.ok) { + let msg = `Error ${resp.status}`; + try { const err = await resp.json(); msg = err.detail || msg; } catch {} + showStatus(msg, "error"); + return; + } + + const data = await resp.json(); + selectedTrackIndex = null; + renderAnalysis(data.analysis); + renderHistory(data.history); + updatePlayerSource(); + showStatus("Track deleted", "success"); + } catch (e) { + showStatus(`Error: ${e.message}`, "error"); + } finally { + deleteTrackBtn.disabled = false; + } +}); + +async function selectTrack(index, name, cardEl) { + // Toggle selection + document.querySelectorAll(".track-card.selected").forEach(c => c.classList.remove("selected")); + + if (selectedTrackIndex === index) { + trackDetail.classList.add("hidden"); + selectedTrackIndex = null; + return; + } + + selectedTrackIndex = index; + cardEl.classList.add("selected"); + detailTrackName.textContent = name; + + // Pre-populate edit controls + if (currentAnalysis && currentAnalysis.tracks[index]) { + const trackData = currentAnalysis.tracks[index]; + const channels = trackData["Channel Assignment"]; + if (channels && channels.length > 0) { + trackChannelSelect.value = channels[0]; + } + const pcs = trackData["Program Changes"]; + if (pcs && pcs.length > 0) { + trackInstrumentSelect.value = pcs[0].program_number; + } else { + trackInstrumentSelect.value = 0; + } + } + + graphContainer.innerHTML = '
Loading...
'; + trackDetail.classList.remove("hidden"); + + try { + const resp = await fetch(`/api/session/${sessionId}/track/${index}`); + if (!resp.ok) { + graphContainer.innerHTML = '
Failed to load track data
'; + return; + } + + const data = await resp.json(); + renderGraphs(data); + } catch (e) { + graphContainer.innerHTML = '
Error loading data
'; + } +} + +function renderGraphs(data) { + graphContainer.innerHTML = ""; + + const totalTicks = data.total_ticks || 1; + + // Piano roll + if (data.notes && data.notes.length > 0) { + addPianoRoll(data.notes, totalTicks); + } + + // Velocity graph + if (data.velocities.length > 0) { + addGraph("Note Velocity", data.velocities, totalTicks, 0, 127, "#69db7c"); + } + + // Pitch bend graph + if (data.pitch_bend.length > 0) { + const minPB = Math.min(...data.pitch_bend.map(p => p[1])); + const maxPB = Math.max(...data.pitch_bend.map(p => p[1])); + addGraph("Pitch Bend", data.pitch_bend, totalTicks, Math.min(minPB, -1), Math.max(maxPB, 1), "#ffd43b", true); + } + + // Control change graphs + for (const [cc, info] of Object.entries(data.control_changes)) { + if (info.data.length > 0) { + addGraph(`${info.name} (CC${cc})`, info.data, totalTicks, 0, 127, "#74c0fc"); + } + } + + if (graphContainer.children.length === 0) { + graphContainer.innerHTML = '
No control data in this track
'; + } +} + +const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + +function addPianoRoll(notes, totalTicks) { + const card = document.createElement("div"); + card.className = "graph-card"; + + const labelDiv = document.createElement("div"); + labelDiv.className = "graph-label"; + labelDiv.textContent = `Piano Roll (${notes.length} notes)`; + card.appendChild(labelDiv); + + const canvas = document.createElement("canvas"); + canvas.className = "piano-roll"; + canvas.height = 300; + card.appendChild(canvas); + + graphContainer.appendChild(card); + + requestAnimationFrame(() => { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const h = 300; + canvas.width = rect.width * dpr; + canvas.height = h * dpr; + + const ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + + const w = rect.width; + const pad = 2; + + // Find note range with padding + let minNote = 127, maxNote = 0; + for (const n of notes) { + if (n[0] < minNote) minNote = n[0]; + if (n[0] > maxNote) maxNote = n[0]; + } + minNote = Math.max(0, minNote - 2); + maxNote = Math.min(127, maxNote + 2); + const noteRange = maxNote - minNote + 1; + const noteHeight = Math.max(1, (h - pad * 2) / noteRange); + + // Background + ctx.fillStyle = "#0f0f23"; + ctx.fillRect(0, 0, w, h); + + // Octave gridlines and labels + ctx.fillStyle = "#888"; + ctx.font = "9px sans-serif"; + ctx.textBaseline = "middle"; + for (let n = minNote; n <= maxNote; n++) { + if (n % 12 === 0) { + const y = h - pad - ((n - minNote + 0.5) / noteRange) * (h - pad * 2); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + + const octave = Math.floor(n / 12) - 1; + ctx.fillStyle = "#555"; + ctx.fillText(`C${octave}`, 2, y); + } + } + + // Draw notes + for (const n of notes) { + const noteNum = n[0], start = n[1], end = n[2], vel = n[3]; + const x = (start / totalTicks) * w; + const nw = Math.max(1, ((end - start) / totalTicks) * w); + const y = h - pad - ((noteNum - minNote + 1) / noteRange) * (h - pad * 2); + + // Velocity-based brightness + const lightness = 30 + (vel / 127) * 35; + ctx.fillStyle = `hsl(145, 60%, ${lightness}%)`; + ctx.fillRect(x, y, nw, Math.max(1, noteHeight - 1)); + } + }); +} + +function addGraph(label, points, totalTicks, minVal, maxVal, color, showZero = false) { + const card = document.createElement("div"); + card.className = "graph-card"; + + const labelDiv = document.createElement("div"); + labelDiv.className = "graph-label"; + labelDiv.textContent = label; + card.appendChild(labelDiv); + + const canvas = document.createElement("canvas"); + canvas.height = 80; + card.appendChild(canvas); + + const rangeDiv = document.createElement("div"); + rangeDiv.className = "graph-range"; + rangeDiv.innerHTML = `${Math.round(minVal)}${Math.round(maxVal)}`; + card.appendChild(rangeDiv); + + graphContainer.appendChild(card); + + // Draw after DOM insertion so width is resolved + requestAnimationFrame(() => { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = 80 * dpr; + + const ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + + const w = rect.width; + const h = 80; + const pad = 2; + const range = maxVal - minVal || 1; + + // Background + ctx.fillStyle = "#0f0f23"; + ctx.fillRect(0, 0, w, h); + + // Zero line for pitch bend + if (showZero && minVal < 0 && maxVal > 0) { + const zeroY = h - pad - ((0 - minVal) / range) * (h - pad * 2); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, zeroY); + ctx.lineTo(w, zeroY); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Data line + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + for (let i = 0; i < points.length; i++) { + const x = (points[i][0] / totalTicks) * w; + const y = h - pad - ((points[i][1] - minVal) / range) * (h - pad * 2); + + // Step-style for CC data (hold value until next change) + if (i > 0 && !showZero) { + const prevY = h - pad - ((points[i - 1][1] - minVal) / range) * (h - pad * 2); + ctx.lineTo(x, prevY); + } + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + // Dot markers if few points + if (points.length <= 50) { + ctx.fillStyle = color; + for (const pt of points) { + const x = (pt[0] / totalTicks) * w; + const y = h - pad - ((pt[1] - minVal) / range) * (h - pad * 2); + ctx.beginPath(); + ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + }); +} + +function showStatus(message, type) { + statusDiv.textContent = message; + statusDiv.className = `status ${type}`; + statusDiv.classList.remove("hidden"); +} diff --git a/server/app/static/index.html b/server/app/static/index.html new file mode 100644 index 0000000..7ff7dfe --- /dev/null +++ b/server/app/static/index.html @@ -0,0 +1,120 @@ + + + + + + MIDI Tools + + + + +
+

MIDI Tools

+ Tesla Coil MIDI Processing Suite +
+ +
+
+

Drop a MIDI file here or click to browse

+ +
+ + +
+ + + + diff --git a/server/app/static/style.css b/server/app/static/style.css new file mode 100644 index 0000000..a8b719e --- /dev/null +++ b/server/app/static/style.css @@ -0,0 +1,625 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.hidden { + display: none !important; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; + min-height: 100vh; +} + +/* Header bar */ +header { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 1rem 2rem; + background: #12122a; + border-bottom: 1px solid #2a2a4a; +} + +header h1 { + font-size: 1.3rem; + color: #fff; +} + +header .subtitle { + color: #666; + font-size: 0.85rem; +} + +/* Main content */ +main { + padding: 1.5rem 2rem; +} + +/* Drop zone */ +.drop-zone { + max-width: 600px; + margin: 4rem auto; + border: 2px dashed #444; + border-radius: 8px; + padding: 3rem; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.drop-zone:hover, +.drop-zone.dragover { + border-color: #6c63ff; + background: rgba(108, 99, 255, 0.05); +} + +.drop-zone p { + color: #888; + font-size: 1rem; +} + +/* File bar */ +.file-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: #16213e; + border-radius: 6px; + margin-bottom: 1.25rem; +} + +#file-name { + flex: 1; + font-size: 0.9rem; +} + +.file-bar-actions { + display: flex; + gap: 0.5rem; +} + +.btn-action { + background: #6c63ff; + border: none; + color: #fff; + padding: 0.3rem 0.7rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; + transition: background 0.2s; +} + +.btn-action:hover { + background: #5a52d5; +} + +.btn-muted { + background: none; + border: 1px solid #555; + color: #aaa; +} + +.btn-muted:hover { + border-color: #888; + color: #fff; + background: none; +} + +/* MIDI Player */ +#midi-player { + flex: 0 1 360px; + min-width: 200px; + --primary-color: #6c63ff; +} + +#midi-player::part(control-panel) { + background: #12122a; + border: 1px solid #2a2a4a; + border-radius: 4px; + padding: 0.2rem 0.5rem; +} + +#midi-player::part(play-button) { + color: #e0e0e0; + border: none; +} + +#midi-player::part(time) { + color: #888; + font-family: inherit; + font-size: 0.75rem; +} + +#midi-player::part(seek-bar) { + accent-color: #6c63ff; +} + +#midi-player::part(loading-overlay) { + background: #12122a; +} + +/* Two-column layout */ +.columns { + display: grid; + grid-template-columns: 1fr 360px; + gap: 1.25rem; + align-items: start; +} + +.panel { + background: #12122a; + border: 1px solid #2a2a4a; + border-radius: 8px; + padding: 1.25rem; +} + +.panel h2 { + font-size: 1rem; + color: #ccc; + margin-bottom: 1rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.panel h3 { + font-size: 0.85rem; + color: #888; + margin-bottom: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +/* Analysis panel */ +.analysis-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.6rem; + margin-bottom: 1.25rem; +} + +.metric-card { + background: #1a1a2e; + border-radius: 6px; + padding: 0.65rem 0.85rem; + border-left: 3px solid #6c63ff; +} + +.metric-card .label { + font-size: 0.7rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.15rem; +} + +.metric-card .value { + font-size: 1.05rem; + color: #fff; + font-weight: 500; +} + +.track-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.5rem; +} + +.track-card { + background: #1a1a2e; + border-radius: 6px; + padding: 0.65rem 0.85rem; +} + +.track-card .track-header { + font-size: 0.9rem; + color: #fff; + font-weight: 500; + margin-bottom: 0.35rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.channel-badge { + font-size: 0.65rem; + background: #6c63ff33; + color: #a9a4ff; + padding: 0.1rem 0.35rem; + border-radius: 3px; + font-weight: 400; +} + +.track-card .detail-row { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + padding: 0.12rem 0; + color: #777; +} + +.track-card .detail-row .detail-value { + color: #bbb; +} + +.hint { + font-size: 0.7rem; + color: #555; + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +/* Tracks header with merge button */ +.tracks-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; +} + +.tracks-header h3 { + margin-bottom: 0; +} + +.track-card .merge-checkbox { + position: absolute; + top: 0.5rem; + right: 0.5rem; + accent-color: #6c63ff; + width: 14px; + height: 14px; +} + +.track-card { + position: relative; +} + +/* Track detail panel */ +.track-detail { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid #2a2a4a; +} + +.track-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.track-detail-header h3 { + margin-bottom: 0; +} + +.track-detail-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.btn-danger { + background: #e03131; +} + +.btn-danger:hover { + background: #c92a2a; +} + +.track-edit-controls { + margin-bottom: 0.75rem; +} + +.track-edit-row { + display: flex; + align-items: flex-end; + gap: 0.75rem; +} + +.track-edit-row .param-group { + flex: 1; +} + +.track-edit-row .param-group select { + width: 100%; + padding: 0.4rem 0.5rem; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #333; + border-radius: 6px; + font-size: 0.85rem; +} + +.track-edit-row .btn-action { + margin-bottom: 0; + white-space: nowrap; +} + +.graph-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.graph-card { + background: #1a1a2e; + border-radius: 6px; + padding: 0.6rem 0.75rem; +} + +.graph-card .graph-label { + font-size: 0.75rem; + color: #888; + margin-bottom: 0.35rem; + font-weight: 500; +} + +.graph-card canvas { + width: 100%; + height: 80px; + display: block; + border-radius: 4px; +} + +.graph-card canvas.piano-roll { + height: 300px; +} + +.graph-card .graph-range { + display: flex; + justify-content: space-between; + font-size: 0.65rem; + color: #555; + margin-top: 0.2rem; +} + +.track-card { + cursor: pointer; + transition: border-color 0.15s; + border: 1px solid transparent; +} + +.track-card:hover { + border-color: #6c63ff55; +} + +.track-card.selected { + border-color: #6c63ff; +} + +/* Tool panel */ +.tool-select select { + width: 100%; + padding: 0.5rem; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #333; + border-radius: 6px; + font-size: 0.9rem; +} + +.tool-description { + color: #777; + font-size: 0.8rem; + margin-top: 0.6rem; + line-height: 1.4; +} + +/* Channel selection */ +.channel-section { + margin-top: 1rem; +} + +.channel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.4rem; +} + +.channel-header label { + font-size: 0.8rem; + color: #aaa; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.btn-link { + background: none; + border: none; + color: #6c63ff; + cursor: pointer; + font-size: 0.75rem; + padding: 0; +} + +.btn-link:hover { + color: #a9a4ff; +} + +.channel-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.channel-checkboxes label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8rem; + color: #ccc; + background: #1a1a2e; + padding: 0.25rem 0.5rem; + border-radius: 4px; + cursor: pointer; + user-select: none; + border: 1px solid transparent; + transition: border-color 0.15s; +} + +.channel-checkboxes label:has(input:checked) { + border-color: #6c63ff55; + background: #6c63ff15; +} + +.channel-checkboxes input[type="checkbox"] { + accent-color: #6c63ff; + width: 14px; + height: 14px; +} + +/* Params */ +.params { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.param-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.param-group label { + font-size: 0.8rem; + color: #aaa; + font-weight: 500; +} + +.param-group input { + padding: 0.45rem 0.6rem; + background: #1a1a2e; + color: #e0e0e0; + border: 1px solid #333; + border-radius: 6px; + font-size: 0.9rem; +} + +.vel-range-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.vel-range-row input { + flex: 1; +} + +.vel-range-sep { + color: #666; + font-size: 0.9rem; +} + +.param-hint { + font-size: 0.7rem; + color: #555; +} + +.btn-primary { + margin-top: 1rem; + width: 100%; + padding: 0.65rem; + background: #6c63ff; + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary:hover:not(:disabled) { + background: #5a52d5; +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.status { + margin-top: 0.75rem; + padding: 0.6rem 0.85rem; + border-radius: 6px; + font-size: 0.85rem; +} + +.status.loading { + background: #16213e; + color: #6c63ff; +} + +.status.error { + background: #2e1a1a; + color: #ff6b6b; +} + +.status.success { + background: #1a2e1a; + color: #69db7c; +} + +/* History */ +.history-section { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid #2a2a4a; +} + +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.history-header h3 { + margin-bottom: 0; +} + +.btn-undo { + background: #e8590c; + font-size: 0.75rem; + padding: 0.25rem 0.6rem; +} + +.btn-undo:hover { + background: #d9480f; +} + +.btn-undo:disabled { + opacity: 0.4; + cursor: not-allowed; + background: #e8590c; +} + +.history-list { + list-style: none; + counter-reset: history; +} + +.history-list li { + counter-increment: history; + font-size: 0.8rem; + color: #aaa; + padding: 0.3rem 0; + border-bottom: 1px solid #1a1a2e; +} + +.history-list li::before { + content: counter(history) ". "; + color: #555; +} diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..4bb7f15 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,6 @@ +services: + midiedit: + build: . + ports: + - "8000:8000" + restart: unless-stopped diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..11b930e --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +python-multipart>=0.0.6 +mido>=1.3.0