Browse Source

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 <noreply@anthropic.com>
master
melancholytron 1 month ago
commit
6df479d320
  1. 11
      .gitignore
  2. 206
      midi-tools/README.md
  3. 171
      midi-tools/back-tempo.py
  4. 171
      midi-tools/baketempo.py
  5. 306
      midi-tools/midicheck.py
  6. 283
      midi-tools/midicheckjson.py
  7. 275
      midi-tools/midicheckjson_single.py
  8. 178
      midi-tools/monofy.py
  9. 122
      midi-tools/reduncheck.py
  10. 61
      midi-tools/type_0.py
  11. 154
      midi-tools/velfix.py
  12. 5
      server/.dockerignore
  13. 12
      server/Dockerfile
  14. 0
      server/app/__init__.py
  15. 0
      server/app/core/__init__.py
  16. 122
      server/app/core/analyze.py
  17. 102
      server/app/core/baketempo.py
  18. 13
      server/app/core/file_handling.py
  19. 72
      server/app/core/midi_utils.py
  20. 122
      server/app/core/monofy.py
  21. 40
      server/app/core/reduncheck.py
  22. 79
      server/app/core/track_detail.py
  23. 12
      server/app/core/type0.py
  24. 30
      server/app/core/velfix.py
  25. 17
      server/app/main.py
  26. 0
      server/app/routers/__init__.py
  27. 432
      server/app/routers/session.py
  28. 897
      server/app/static/app.js
  29. 120
      server/app/static/index.html
  30. 625
      server/app/static/style.css
  31. 6
      server/docker-compose.yml
  32. 4
      server/requirements.txt

11
.gitignore

@ -0,0 +1,11 @@
.claude/
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
dist/
build/
.venv/
venv/
nul

206
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.
---

171
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()

171
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()

306
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()

283
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()

275
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()

178
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)

122
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()

61
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 <input_midi_file>")
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()

154
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()

5
server/.dockerignore

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.mid
*.midi
.git/

12
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"]

0
server/app/__init__.py

0
server/app/core/__init__.py

122
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

102
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

13
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()

72
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

122
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

40
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

79
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
}

12
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

30
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

17
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"))

0
server/app/routers/__init__.py

432
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

897
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 = '<div class="metric-card"><div class="label">Status</div><div class="value">Uploading...</div></div>';
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 = `<div class="label">${label}</div><div class="value">${value}</div>`;
analysisGrid.appendChild(card);
}
function addDetail(parent, label, value) {
const row = document.createElement("div");
row.className = "detail-row";
row.innerHTML = `<span>${label}</span><span class="detail-value">${value}</span>`;
parent.appendChild(row);
}
// Track checkboxes
function buildTrackCheckboxes() {
trackCheckboxes.innerHTML = "";
if (!currentAnalysis) return;
currentAnalysis.tracks.forEach((track, idx) => {
const label = document.createElement("label");
label.innerHTML = `<input type="checkbox" value="${idx}" checked> ${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 = '<div class="graph-card"><div class="graph-label">Loading...</div></div>';
trackDetail.classList.remove("hidden");
try {
const resp = await fetch(`/api/session/${sessionId}/track/${index}`);
if (!resp.ok) {
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">Failed to load track data</div></div>';
return;
}
const data = await resp.json();
renderGraphs(data);
} catch (e) {
graphContainer.innerHTML = '<div class="graph-card"><div class="graph-label">Error loading data</div></div>';
}
}
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 = '<div class="graph-card"><div class="graph-label">No control data in this track</div></div>';
}
}
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 = `<span>${Math.round(minVal)}</span><span>${Math.round(maxVal)}</span>`;
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");
}

120
server/app/static/index.html

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIDI Tools</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14.7.58,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.5.0"></script>
</head>
<body>
<header>
<h1>MIDI Tools</h1>
<span class="subtitle">Tesla Coil MIDI Processing Suite</span>
</header>
<main>
<div id="drop-zone" class="drop-zone">
<p>Drop a MIDI file here or click to browse</p>
<input type="file" id="file-input" accept=".mid,.midi" hidden>
</div>
<div id="workspace" class="workspace hidden">
<div class="file-bar">
<span id="file-name"></span>
<midi-player id="midi-player" sound-font></midi-player>
<div class="file-bar-actions">
<button id="download-btn" class="btn-action">Download</button>
<button id="clear-file" class="btn-action btn-muted">New File</button>
</div>
</div>
<div class="columns">
<!-- Left: Analysis -->
<section class="panel analysis-panel">
<h2>File Analysis</h2>
<div class="analysis-grid" id="analysis-grid"></div>
<div class="tracks-header">
<h3>Tracks <span class="hint">(click a track for details)</span></h3>
<button id="merge-btn" class="btn-action btn-muted" disabled>Merge Selected</button>
</div>
<div id="track-list" class="track-list"></div>
<div id="track-detail" class="track-detail hidden">
<div class="track-detail-header">
<h3 id="detail-track-name">Track Detail</h3>
<div class="track-detail-actions">
<button id="delete-track-btn" class="btn-action btn-danger">Delete Track</button>
<button id="close-detail" class="btn-link">Close</button>
</div>
</div>
<div id="track-edit-controls" class="track-edit-controls">
<div class="track-edit-row">
<div class="param-group">
<label for="track-channel">Channel</label>
<select id="track-channel"></select>
</div>
<div class="param-group">
<label for="track-instrument">Instrument</label>
<select id="track-instrument"></select>
</div>
<button id="track-edit-save" class="btn-action">Save</button>
</div>
</div>
<div id="graph-container" class="graph-container"></div>
</div>
</section>
<!-- Right: Tools + History -->
<section class="panel tool-panel">
<h2>Process</h2>
<div class="tool-select">
<select id="tool">
<option value="baketempo">Bake Tempo</option>
<option value="monofy">Monofy (Split Polyphonic)</option>
<option value="reduncheck">Remove Redundancy</option>
<option value="velfix">Velocity Fix</option>
<option value="type0">Convert to Type 0</option>
</select>
</div>
<p id="tool-description" class="tool-description"></p>
<div id="track-section" class="channel-section hidden">
<div class="channel-header">
<label>Tracks</label>
<button id="track-toggle" class="btn-link">Deselect All</button>
</div>
<div id="track-checkboxes" class="channel-checkboxes"></div>
</div>
<div id="params" class="params hidden">
<div id="param-velocity" class="param-group hidden">
<label>Velocity Range</label>
<div class="vel-range-row">
<input type="number" id="vel-min" min="0" max="127" value="1" placeholder="Min">
<span class="vel-range-sep">&ndash;</span>
<input type="number" id="vel-max" min="0" max="127" value="127" placeholder="Max">
</div>
<span class="param-hint">Remaps note velocities into this range (0-127)</span>
</div>
</div>
<button id="process-btn" class="btn-primary" disabled>Apply</button>
<div id="status" class="status hidden"></div>
<div id="history-section" class="history-section hidden">
<div class="history-header">
<h3>Edit History</h3>
<button id="undo-btn" class="btn-action btn-undo">Undo</button>
</div>
<ol id="history-list" class="history-list"></ol>
</div>
</section>
</div>
</div>
</main>
<script src="/static/app.js"></script>
</body>
</html>

625
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;
}

6
server/docker-compose.yml

@ -0,0 +1,6 @@
services:
midiedit:
build: .
ports:
- "8000:8000"
restart: unless-stopped

4
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
Loading…
Cancel
Save