Spaces:
Running
Running
2 octive keys, keyboard utility, terminal for visuals and refactor for simplicity
Browse files- .gitignore +1 -0
- README.md +25 -0
- app.py +92 -26
- keyboard.html +48 -205
- main.py +0 -109
- static/README.md +18 -0
- static/keyboard.js +460 -0
- static/styles.css +113 -0
.gitignore
CHANGED
|
@@ -73,3 +73,4 @@ coverage.xml
|
|
| 73 |
# OS files
|
| 74 |
.DS_Store
|
| 75 |
Thumbs.db
|
|
|
|
|
|
| 73 |
# OS files
|
| 74 |
.DS_Store
|
| 75 |
Thumbs.db
|
| 76 |
+
*.bak
|
README.md
CHANGED
|
@@ -14,6 +14,31 @@ short_description: A small virtual midi keyboard
|
|
| 14 |
|
| 15 |
Minimal browser MIDI keyboard: play in the browser, record note events, export a .mid file.
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
## Files
|
| 18 |
|
| 19 |
- app.py: Gradio app + MIDI export API
|
|
|
|
| 14 |
|
| 15 |
Minimal browser MIDI keyboard: play in the browser, record note events, export a .mid file.
|
| 16 |
|
| 17 |
+
## Features
|
| 18 |
+
|
| 19 |
+
- 🎹 Two-octave virtual piano keyboard
|
| 20 |
+
- 🎵 Multiple instrument sounds (Synth, Piano, Organ, Bass, Pluck, FM)
|
| 21 |
+
- ⌨️ Computer keyboard input support
|
| 22 |
+
- 📹 MIDI event recording with timestamps
|
| 23 |
+
- 💾 Export recordings as .mid files
|
| 24 |
+
- 📊 Real-time MIDI event monitor
|
| 25 |
+
- 🎨 Clean, responsive interface
|
| 26 |
+
|
| 27 |
+
## Project Structure
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
virtual_keyboard/
|
| 31 |
+
├── app.py # Gradio server + MIDI conversion
|
| 32 |
+
├── keyboard.html # Main UI structure
|
| 33 |
+
├── static/
|
| 34 |
+
│ ├── styles.css # All application styles
|
| 35 |
+
│ ├── keyboard.js # Client-side logic
|
| 36 |
+
│ └── README.md # Static assets documentation
|
| 37 |
+
├── requirements.txt # Python dependencies
|
| 38 |
+
├── pyproject.toml # Project metadata
|
| 39 |
+
└── README.md # This file
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
## Files
|
| 43 |
|
| 44 |
- app.py: Gradio app + MIDI export API
|
app.py
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import base64
|
| 2 |
import html
|
| 3 |
import io
|
|
@@ -8,57 +25,81 @@ from mido import MidiFile, MidiTrack, Message, MetaMessage
|
|
| 8 |
import gradio as gr
|
| 9 |
|
| 10 |
|
| 11 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
def events_to_midbytes(events, ticks_per_beat=480, tempo_bpm=120):
|
| 13 |
"""
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
mid = MidiFile(ticks_per_beat=ticks_per_beat)
|
| 18 |
track = MidiTrack()
|
| 19 |
mid.tracks.append(track)
|
| 20 |
-
|
|
|
|
| 21 |
tempo = mido.bpm2tempo(tempo_bpm)
|
| 22 |
track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
|
| 23 |
|
| 24 |
-
#
|
| 25 |
evs = sorted(events, key=lambda e: e.get("time", 0.0))
|
| 26 |
last_time = 0.0
|
| 27 |
-
# convert delta seconds -> delta ticks
|
| 28 |
for ev in evs:
|
| 29 |
-
#
|
| 30 |
if "time" not in ev or "type" not in ev or "note" not in ev:
|
| 31 |
continue
|
|
|
|
|
|
|
| 32 |
dt_sec = max(0.0, ev["time"] - last_time)
|
| 33 |
last_time = ev["time"]
|
| 34 |
-
|
| 35 |
-
|
| 36 |
ev_type = ev["type"]
|
| 37 |
note = int(ev["note"])
|
| 38 |
vel = int(ev.get("velocity", 0))
|
| 39 |
channel = int(ev.get("channel", 0))
|
|
|
|
| 40 |
if ev_type == "note_on":
|
| 41 |
msg = Message(
|
| 42 |
"note_on", note=note, velocity=vel, time=ticks, channel=channel
|
| 43 |
)
|
| 44 |
else:
|
| 45 |
-
# treat anything else as note_off for now
|
| 46 |
msg = Message(
|
| 47 |
"note_off", note=note, velocity=vel, time=ticks, channel=channel
|
| 48 |
)
|
|
|
|
| 49 |
track.append(msg)
|
| 50 |
|
| 51 |
-
#
|
| 52 |
buf = io.BytesIO()
|
| 53 |
mid.save(file=buf)
|
| 54 |
buf.seek(0)
|
| 55 |
return buf.read()
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def save_midi_api(events):
|
| 59 |
"""
|
| 60 |
-
Gradio API
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"""
|
| 63 |
if not isinstance(events, list) or len(events) == 0:
|
| 64 |
return {"error": "No events provided"}
|
|
@@ -68,28 +109,53 @@ def save_midi_api(events):
|
|
| 68 |
return {"midi_base64": midi_b64}
|
| 69 |
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
iframe_html = (
|
| 75 |
'<iframe srcdoc="'
|
| 76 |
+ html.escape(keyboard_html, quote=True)
|
| 77 |
-
+ '" style="width:100%;height:
|
| 78 |
)
|
| 79 |
|
| 80 |
-
#
|
| 81 |
with gr.Blocks() as demo:
|
| 82 |
gr.HTML(iframe_html)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
if __name__ == "__main__":
|
| 95 |
demo.launch()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Virtual MIDI Keyboard - Gradio Application
|
| 3 |
+
|
| 4 |
+
This application provides a browser-based MIDI keyboard that can:
|
| 5 |
+
- Play notes with various synthesizer sounds (Tone.js)
|
| 6 |
+
- Record MIDI events with timestamps
|
| 7 |
+
- Export recordings as .mid files
|
| 8 |
+
- Support computer keyboard input
|
| 9 |
+
- Monitor MIDI events in real-time
|
| 10 |
+
|
| 11 |
+
File Structure:
|
| 12 |
+
- app.py: Gradio server and MIDI conversion (this file)
|
| 13 |
+
- keyboard.html: Main UI structure
|
| 14 |
+
- static/styles.css: All application styles
|
| 15 |
+
- static/keyboard.js: Client-side logic and interactivity
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
import base64
|
| 19 |
import html
|
| 20 |
import io
|
|
|
|
| 25 |
import gradio as gr
|
| 26 |
|
| 27 |
|
| 28 |
+
# =============================================================================
|
| 29 |
+
# MIDI CONVERSION
|
| 30 |
+
# =============================================================================
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def events_to_midbytes(events, ticks_per_beat=480, tempo_bpm=120):
|
| 34 |
"""
|
| 35 |
+
Convert browser MIDI events to a .mid file format.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
events: List of dicts with {type, note, velocity, time, channel}
|
| 39 |
+
ticks_per_beat: MIDI ticks per beat resolution (default: 480)
|
| 40 |
+
tempo_bpm: Tempo in beats per minute (default: 120)
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
bytes: Complete MIDI file as bytes
|
| 44 |
"""
|
| 45 |
mid = MidiFile(ticks_per_beat=ticks_per_beat)
|
| 46 |
track = MidiTrack()
|
| 47 |
mid.tracks.append(track)
|
| 48 |
+
|
| 49 |
+
# Set tempo meta message
|
| 50 |
tempo = mido.bpm2tempo(tempo_bpm)
|
| 51 |
track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
|
| 52 |
|
| 53 |
+
# Sort events by time and convert to MIDI messages
|
| 54 |
evs = sorted(events, key=lambda e: e.get("time", 0.0))
|
| 55 |
last_time = 0.0
|
|
|
|
| 56 |
for ev in evs:
|
| 57 |
+
# Skip malformed events
|
| 58 |
if "time" not in ev or "type" not in ev or "note" not in ev:
|
| 59 |
continue
|
| 60 |
+
|
| 61 |
+
# Calculate delta time in ticks
|
| 62 |
dt_sec = max(0.0, ev["time"] - last_time)
|
| 63 |
last_time = ev["time"]
|
| 64 |
+
ticks = int(round(dt_sec * (ticks_per_beat * tempo_bpm) / 60.0))
|
| 65 |
+
# Create MIDI message
|
| 66 |
ev_type = ev["type"]
|
| 67 |
note = int(ev["note"])
|
| 68 |
vel = int(ev.get("velocity", 0))
|
| 69 |
channel = int(ev.get("channel", 0))
|
| 70 |
+
|
| 71 |
if ev_type == "note_on":
|
| 72 |
msg = Message(
|
| 73 |
"note_on", note=note, velocity=vel, time=ticks, channel=channel
|
| 74 |
)
|
| 75 |
else:
|
|
|
|
| 76 |
msg = Message(
|
| 77 |
"note_off", note=note, velocity=vel, time=ticks, channel=channel
|
| 78 |
)
|
| 79 |
+
|
| 80 |
track.append(msg)
|
| 81 |
|
| 82 |
+
# Write to bytes buffer
|
| 83 |
buf = io.BytesIO()
|
| 84 |
mid.save(file=buf)
|
| 85 |
buf.seek(0)
|
| 86 |
return buf.read()
|
| 87 |
|
| 88 |
|
| 89 |
+
# =============================================================================
|
| 90 |
+
# API ENDPOINT
|
| 91 |
+
# =============================================================================
|
| 92 |
+
|
| 93 |
+
|
| 94 |
def save_midi_api(events):
|
| 95 |
"""
|
| 96 |
+
Gradio API endpoint for converting recorded events to MIDI file.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
events: List of MIDI event dictionaries from the browser
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Dict with 'midi_base64' or 'error' key
|
| 103 |
"""
|
| 104 |
if not isinstance(events, list) or len(events) == 0:
|
| 105 |
return {"error": "No events provided"}
|
|
|
|
| 109 |
return {"midi_base64": midi_b64}
|
| 110 |
|
| 111 |
|
| 112 |
+
# =============================================================================
|
| 113 |
+
# GRADIO APPLICATION
|
| 114 |
+
# =============================================================================
|
| 115 |
+
|
| 116 |
+
# Load and combine HTML, CSS, and JS for the iframe
|
| 117 |
+
with open("keyboard.html", "r", encoding="utf-8") as f:
|
| 118 |
+
html_content = f.read()
|
| 119 |
+
|
| 120 |
+
with open("static/styles.css", "r", encoding="utf-8") as f:
|
| 121 |
+
css_content = f.read()
|
| 122 |
+
|
| 123 |
+
with open("static/keyboard.js", "r", encoding="utf-8") as f:
|
| 124 |
+
js_content = f.read()
|
| 125 |
+
|
| 126 |
+
# Inject CSS and JS into the HTML
|
| 127 |
+
keyboard_html = html_content.replace(
|
| 128 |
+
'<link rel="stylesheet" href="/file=static/styles.css" />',
|
| 129 |
+
f"<style>{css_content}</style>",
|
| 130 |
+
).replace(
|
| 131 |
+
'<script src="/file=static/keyboard.js"></script>', f"<script>{js_content}</script>"
|
| 132 |
+
)
|
| 133 |
|
| 134 |
iframe_html = (
|
| 135 |
'<iframe srcdoc="'
|
| 136 |
+ html.escape(keyboard_html, quote=True)
|
| 137 |
+
+ '" style="width:100%;height:550px;border:0;"></iframe>'
|
| 138 |
)
|
| 139 |
|
| 140 |
+
# Create Gradio interface
|
| 141 |
with gr.Blocks() as demo:
|
| 142 |
gr.HTML(iframe_html)
|
| 143 |
+
|
| 144 |
+
# Hidden API endpoint components (required for Gradio 6.x)
|
| 145 |
+
with gr.Row(visible=False):
|
| 146 |
+
api_input = gr.JSON()
|
| 147 |
+
api_output = gr.JSON()
|
| 148 |
+
api_btn = gr.Button("save")
|
| 149 |
+
|
| 150 |
+
# Connect API endpoint
|
| 151 |
+
api_btn.click(
|
| 152 |
+
fn=save_midi_api, inputs=api_input, outputs=api_output, api_name="save_midi"
|
| 153 |
)
|
| 154 |
|
| 155 |
+
|
| 156 |
+
# =============================================================================
|
| 157 |
+
# MAIN
|
| 158 |
+
# =============================================================================
|
| 159 |
+
|
| 160 |
if __name__ == "__main__":
|
| 161 |
demo.launch()
|
keyboard.html
CHANGED
|
@@ -1,216 +1,59 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
-
<title>Virtual MIDI Keyboard
|
| 6 |
-
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 7 |
-
<
|
| 8 |
-
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; padding: 12px; }
|
| 9 |
-
#keyboard { display:flex; user-select:none; }
|
| 10 |
-
.key { width:42px; height:180px; border:1px solid #333; margin:0 1px; background:white; position:relative; display:flex; align-items:flex-end; justify-content:center; cursor:pointer; }
|
| 11 |
-
.key.black { width:30px; height:110px; background:#222; color:white; margin-left:-15px; margin-right:-15px; z-index:2; position:relative; }
|
| 12 |
-
.controls { margin-top:10px; display:flex; gap:8px; align-items:center; }
|
| 13 |
-
button { padding:8px 12px; }
|
| 14 |
-
#status { margin-left:8px; color:#666; }
|
| 15 |
-
#downloadLink { display:inline-block; margin-left:8px; }
|
| 16 |
-
</style>
|
| 17 |
</head>
|
| 18 |
<body>
|
| 19 |
<h3>Virtual MIDI Keyboard (prototype)</h3>
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
|
|
|
|
| 29 |
<script src="https://unpkg.com/tone@next/build/Tone.js"></script>
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
// keys order with sharps flagged
|
| 34 |
-
const keys = [
|
| 35 |
-
{name:'C', offset:0, black:false},
|
| 36 |
-
{name:'C#', offset:1, black:true},
|
| 37 |
-
{name:'D', offset:2, black:false},
|
| 38 |
-
{name:'D#', offset:3, black:true},
|
| 39 |
-
{name:'E', offset:4, black:false},
|
| 40 |
-
{name:'F', offset:5, black:false},
|
| 41 |
-
{name:'F#', offset:6, black:true},
|
| 42 |
-
{name:'G', offset:7, black:false},
|
| 43 |
-
{name:'G#', offset:8, black:true},
|
| 44 |
-
{name:'A', offset:9, black:false},
|
| 45 |
-
{name:'A#', offset:10, black:true},
|
| 46 |
-
{name:'B', offset:11, black:false}
|
| 47 |
-
];
|
| 48 |
-
|
| 49 |
-
// build keyboard DOM
|
| 50 |
-
const keyboardEl = document.getElementById('keyboard');
|
| 51 |
-
// create white key container so black keys can overlap visually
|
| 52 |
-
for (let i=0;i<keys.length;i++){
|
| 53 |
-
const k = keys[i];
|
| 54 |
-
const keyEl = document.createElement('div');
|
| 55 |
-
keyEl.className = 'key' + (k.black? ' black': '');
|
| 56 |
-
keyEl.dataset.midi = baseMidi + k.offset;
|
| 57 |
-
keyEl.innerHTML = `<div style="padding-bottom:6px;font-size:11px">${k.name}${4}</div>`;
|
| 58 |
-
keyboardEl.appendChild(keyEl);
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
// Tone.js synth
|
| 62 |
-
const synth = new Tone.PolySynth(Tone.Synth).toDestination();
|
| 63 |
-
// small envelope tweak for nicer touch
|
| 64 |
-
synth.set({options:{portamento:0}, voice: {oscillator: {type: 'sine'}}});
|
| 65 |
-
|
| 66 |
-
// recording state
|
| 67 |
-
let recording = false;
|
| 68 |
-
let startTime = 0;
|
| 69 |
-
let events = []; // {type, note, velocity, time, channel}
|
| 70 |
-
|
| 71 |
-
const statusEl = document.getElementById('status');
|
| 72 |
-
const recordBtn = document.getElementById('recordBtn');
|
| 73 |
-
const stopBtn = document.getElementById('stopBtn');
|
| 74 |
-
const saveBtn = document.getElementById('saveBtn');
|
| 75 |
-
|
| 76 |
-
function nowSec(){ return performance.now()/1000; }
|
| 77 |
-
|
| 78 |
-
function beginRecord(){
|
| 79 |
-
events = [];
|
| 80 |
-
recording = true;
|
| 81 |
-
startTime = nowSec();
|
| 82 |
-
statusEl.textContent = 'Recording...';
|
| 83 |
-
recordBtn.disabled = true;
|
| 84 |
-
stopBtn.disabled = false;
|
| 85 |
-
saveBtn.disabled = true;
|
| 86 |
-
}
|
| 87 |
-
function stopRecord(){
|
| 88 |
-
recording = false;
|
| 89 |
-
statusEl.textContent = `Recorded ${events.length} events`;
|
| 90 |
-
recordBtn.disabled = false;
|
| 91 |
-
stopBtn.disabled = true;
|
| 92 |
-
saveBtn.disabled = events.length === 0;
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
function noteOn(midiNote, velocity=100){
|
| 96 |
-
const freq = Tone.Frequency(midiNote, "midi").toFrequency();
|
| 97 |
-
synth.triggerAttack(freq, undefined, velocity/127);
|
| 98 |
-
if (recording) {
|
| 99 |
-
events.push({type:'note_on', note: midiNote, velocity: Math.max(1, velocity|0), time: nowSec() - startTime, channel: 0});
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
function noteOff(midiNote){
|
| 103 |
-
const freq = Tone.Frequency(midiNote, "midi").toFrequency();
|
| 104 |
-
synth.triggerRelease(freq);
|
| 105 |
-
if (recording) {
|
| 106 |
-
events.push({type:'note_off', note: midiNote, velocity: 0, time: nowSec() - startTime, channel: 0});
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
// attach pointer/mouse/touch events
|
| 111 |
-
keyboardEl.querySelectorAll('.key').forEach(k => {
|
| 112 |
-
let pressed = false;
|
| 113 |
-
k.addEventListener('pointerdown', (ev) => {
|
| 114 |
-
ev.preventDefault();
|
| 115 |
-
k.setPointerCapture(ev.pointerId);
|
| 116 |
-
if (!pressed){
|
| 117 |
-
pressed = true;
|
| 118 |
-
k.style.filter = 'brightness(0.85)';
|
| 119 |
-
const midi = parseInt(k.dataset.midi);
|
| 120 |
-
// velocity derived from pointer pressure if available
|
| 121 |
-
const vel = ev.pressure ? Math.round(ev.pressure * 127) : 100;
|
| 122 |
-
noteOn(midi, vel);
|
| 123 |
-
}
|
| 124 |
-
});
|
| 125 |
-
k.addEventListener('pointerup', (ev) => {
|
| 126 |
-
ev.preventDefault();
|
| 127 |
-
if (pressed){
|
| 128 |
-
pressed = false;
|
| 129 |
-
k.style.filter = '';
|
| 130 |
-
const midi = parseInt(k.dataset.midi);
|
| 131 |
-
noteOff(midi);
|
| 132 |
-
}
|
| 133 |
-
});
|
| 134 |
-
k.addEventListener('pointerleave', (ev) => {
|
| 135 |
-
// if pointer leaves while pressed, release
|
| 136 |
-
if (pressed){
|
| 137 |
-
pressed = false;
|
| 138 |
-
k.style.filter = '';
|
| 139 |
-
const midi = parseInt(k.dataset.midi);
|
| 140 |
-
noteOff(midi);
|
| 141 |
-
}
|
| 142 |
-
});
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
// buttons
|
| 146 |
-
recordBtn.addEventListener('click', async () => {
|
| 147 |
-
// start audio context on first user interaction (required by browsers)
|
| 148 |
-
await Tone.start();
|
| 149 |
-
beginRecord();
|
| 150 |
-
});
|
| 151 |
-
|
| 152 |
-
stopBtn.addEventListener('click', () => stopRecord());
|
| 153 |
-
|
| 154 |
-
saveBtn.addEventListener('click', async () => {
|
| 155 |
-
if (recording) stopRecord();
|
| 156 |
-
if (events.length === 0) return alert('No events recorded.');
|
| 157 |
-
|
| 158 |
-
statusEl.textContent = 'Uploading…';
|
| 159 |
-
saveBtn.disabled = true;
|
| 160 |
-
try {
|
| 161 |
-
const startResp = await fetch('/gradio_api/call/save_midi', {
|
| 162 |
-
method: 'POST',
|
| 163 |
-
headers: {'Content-Type':'application/json'},
|
| 164 |
-
body: JSON.stringify({data: [events]})
|
| 165 |
-
});
|
| 166 |
-
if (!startResp.ok) {
|
| 167 |
-
const txt = await startResp.text();
|
| 168 |
-
throw new Error('Server error: ' + txt);
|
| 169 |
-
}
|
| 170 |
-
const startJson = await startResp.json();
|
| 171 |
-
if (!startJson || !startJson.event_id) {
|
| 172 |
-
throw new Error('Invalid API response');
|
| 173 |
-
}
|
| 174 |
-
const resultResp = await fetch(`/gradio_api/call/save_midi/${startJson.event_id}`);
|
| 175 |
-
if (!resultResp.ok) {
|
| 176 |
-
const txt = await resultResp.text();
|
| 177 |
-
throw new Error('Server error: ' + txt);
|
| 178 |
-
}
|
| 179 |
-
const resultText = await resultResp.text();
|
| 180 |
-
const dataLine = resultText.split('\n').find(line => line.startsWith('data:'));
|
| 181 |
-
if (!dataLine) {
|
| 182 |
-
throw new Error('Invalid API response');
|
| 183 |
-
}
|
| 184 |
-
const payloadList = JSON.parse(dataLine.replace('data:', '').trim());
|
| 185 |
-
const payload = Array.isArray(payloadList) ? payloadList[0] : null;
|
| 186 |
-
if (!payload || payload.error || !payload.midi_base64) {
|
| 187 |
-
throw new Error(payload && payload.error ? payload.error : 'Invalid API response');
|
| 188 |
-
}
|
| 189 |
-
const binStr = atob(payload.midi_base64);
|
| 190 |
-
const bytes = new Uint8Array(binStr.length);
|
| 191 |
-
for (let i = 0; i < binStr.length; i++) {
|
| 192 |
-
bytes[i] = binStr.charCodeAt(i);
|
| 193 |
-
}
|
| 194 |
-
const blob = new Blob([bytes], {type: 'audio/midi'});
|
| 195 |
-
const url = URL.createObjectURL(blob);
|
| 196 |
-
const a = document.createElement('a');
|
| 197 |
-
a.href = url;
|
| 198 |
-
a.download = 'recording.mid';
|
| 199 |
-
a.click();
|
| 200 |
-
statusEl.textContent = 'Downloaded .mid';
|
| 201 |
-
} catch (err){
|
| 202 |
-
console.error(err);
|
| 203 |
-
statusEl.textContent = 'Error saving MIDI';
|
| 204 |
-
alert('Error: ' + err.message);
|
| 205 |
-
} finally {
|
| 206 |
-
saveBtn.disabled = false;
|
| 207 |
-
}
|
| 208 |
-
});
|
| 209 |
-
|
| 210 |
-
// initialize button states
|
| 211 |
-
recordBtn.disabled = false;
|
| 212 |
-
stopBtn.disabled = true;
|
| 213 |
-
saveBtn.disabled = true;
|
| 214 |
-
</script>
|
| 215 |
</body>
|
| 216 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
+
<title>Virtual MIDI Keyboard</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<link rel="stylesheet" href="/file=static/styles.css" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<h3>Virtual MIDI Keyboard (prototype)</h3>
|
| 11 |
+
|
| 12 |
+
<div id="mainContainer">
|
| 13 |
+
<!-- Left Panel: Keyboard and Controls -->
|
| 14 |
+
<div id="leftPanel">
|
| 15 |
+
<div id="keyboard"></div>
|
| 16 |
+
|
| 17 |
+
<div class="controls">
|
| 18 |
+
<label>
|
| 19 |
+
Instrument:
|
| 20 |
+
<select id="instrumentSelect">
|
| 21 |
+
<option value="synth">Synth</option>
|
| 22 |
+
<option value="piano">Piano</option>
|
| 23 |
+
<option value="organ">Organ</option>
|
| 24 |
+
<option value="bass">Bass</option>
|
| 25 |
+
<option value="pluck">Pluck</option>
|
| 26 |
+
<option value="fm">FM Synth</option>
|
| 27 |
+
</select>
|
| 28 |
+
</label>
|
| 29 |
+
|
| 30 |
+
<button id="recordBtn">Record</button>
|
| 31 |
+
<button id="stopBtn" disabled>Stop</button>
|
| 32 |
+
<button id="saveBtn" disabled>Save MIDI</button>
|
| 33 |
+
|
| 34 |
+
<label>
|
| 35 |
+
<input type="checkbox" id="keyboardToggle">
|
| 36 |
+
Enable Keyboard Input
|
| 37 |
+
</label>
|
| 38 |
+
|
| 39 |
+
<span id="status">Idle</span>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<!-- Right Panel: MIDI Monitor -->
|
| 44 |
+
<div id="rightPanel">
|
| 45 |
+
<div class="terminal-header">
|
| 46 |
+
<h4 style="margin: 0;">MIDI Monitor</h4>
|
| 47 |
+
<button id="clearTerminal">Clear</button>
|
| 48 |
+
</div>
|
| 49 |
+
<div id="terminal"></div>
|
| 50 |
+
</div>
|
| 51 |
</div>
|
| 52 |
|
| 53 |
+
<!-- External Dependencies -->
|
| 54 |
<script src="https://unpkg.com/tone@next/build/Tone.js"></script>
|
| 55 |
+
|
| 56 |
+
<!-- Application Logic -->
|
| 57 |
+
<script src="/file=static/keyboard.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</body>
|
| 59 |
</html>
|
main.py
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 1 |
-
# main.py
|
| 2 |
-
import io
|
| 3 |
-
import json
|
| 4 |
-
from fastapi import FastAPI, Request, Response
|
| 5 |
-
from fastapi.responses import HTMLResponse
|
| 6 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
import mido
|
| 8 |
-
from mido import MidiFile, MidiTrack, Message, MetaMessage
|
| 9 |
-
|
| 10 |
-
import gradio as gr
|
| 11 |
-
|
| 12 |
-
app = FastAPI(title="Virtual MIDI Keyboard - prototype")
|
| 13 |
-
|
| 14 |
-
# allow local testing from browsers (same host) — adjust in production
|
| 15 |
-
app.add_middleware(
|
| 16 |
-
CORSMiddleware,
|
| 17 |
-
allow_origins=["*"], # in prod lock this down
|
| 18 |
-
allow_credentials=True,
|
| 19 |
-
allow_methods=["*"],
|
| 20 |
-
allow_headers=["*"],
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
# --- MIDI conversion helper ---
|
| 25 |
-
def events_to_midbytes(events, ticks_per_beat=480, tempo_bpm=120):
|
| 26 |
-
"""
|
| 27 |
-
events: list of {type:'note_on'|'note_off', note:int, velocity:int, time:float (seconds), channel:int}
|
| 28 |
-
returns: bytes of a .mid file
|
| 29 |
-
"""
|
| 30 |
-
mid = MidiFile(ticks_per_beat=ticks_per_beat)
|
| 31 |
-
track = MidiTrack()
|
| 32 |
-
mid.tracks.append(track)
|
| 33 |
-
# set tempo meta message
|
| 34 |
-
tempo = mido.bpm2tempo(tempo_bpm)
|
| 35 |
-
track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
|
| 36 |
-
|
| 37 |
-
# sort by absolute time
|
| 38 |
-
evs = sorted(events, key=lambda e: e.get("time", 0.0))
|
| 39 |
-
last_time = 0.0
|
| 40 |
-
# convert delta seconds -> delta ticks
|
| 41 |
-
for ev in evs:
|
| 42 |
-
# safety: skip malformed
|
| 43 |
-
if "time" not in ev or "type" not in ev or "note" not in ev:
|
| 44 |
-
continue
|
| 45 |
-
dt_sec = max(0.0, ev["time"] - last_time)
|
| 46 |
-
last_time = ev["time"]
|
| 47 |
-
# ticks = seconds * ticks_per_beat * bpm / 60
|
| 48 |
-
ticks = int(round(dt_sec * (ticks_per_beat * (tempo_bpm)) / 60.0))
|
| 49 |
-
ev_type = ev["type"]
|
| 50 |
-
note = int(ev["note"])
|
| 51 |
-
vel = int(ev.get("velocity", 0))
|
| 52 |
-
channel = int(ev.get("channel", 0))
|
| 53 |
-
if ev_type == "note_on":
|
| 54 |
-
msg = Message(
|
| 55 |
-
"note_on", note=note, velocity=vel, time=ticks, channel=channel
|
| 56 |
-
)
|
| 57 |
-
else:
|
| 58 |
-
# treat anything else as note_off for now
|
| 59 |
-
msg = Message(
|
| 60 |
-
"note_off", note=note, velocity=vel, time=ticks, channel=channel
|
| 61 |
-
)
|
| 62 |
-
track.append(msg)
|
| 63 |
-
|
| 64 |
-
# write to bytes
|
| 65 |
-
buf = io.BytesIO()
|
| 66 |
-
mid.save(file=buf)
|
| 67 |
-
buf.seek(0)
|
| 68 |
-
return buf.read()
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# --- FastAPI route: accepts events JSON and returns .mid bytes ---
|
| 72 |
-
@app.post("/save_midi")
|
| 73 |
-
async def save_midi(request: Request):
|
| 74 |
-
"""
|
| 75 |
-
Expects JSON: {"events": [ {type, note, velocity, time, channel}, ... ]}
|
| 76 |
-
Returns: application/octet-stream or audio/midi bytes of a .mid file
|
| 77 |
-
"""
|
| 78 |
-
try:
|
| 79 |
-
payload = await request.json()
|
| 80 |
-
except Exception as e:
|
| 81 |
-
return Response(content=f"Invalid JSON: {e}", status_code=400)
|
| 82 |
-
|
| 83 |
-
events = payload.get("events")
|
| 84 |
-
if not isinstance(events, list) or len(events) == 0:
|
| 85 |
-
return Response(content="No events provided", status_code=400)
|
| 86 |
-
|
| 87 |
-
mid_bytes = events_to_midbytes(events)
|
| 88 |
-
return Response(content=mid_bytes, media_type="audio/midi")
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
# --- FastAPI route: serve the standalone keyboard HTML ---
|
| 92 |
-
@app.get("/keyboard", response_class=HTMLResponse)
|
| 93 |
-
async def keyboard_page():
|
| 94 |
-
with open("keyboard.html", "r", encoding="utf-8") as handle:
|
| 95 |
-
return handle.read()
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
# --- Gradio UI: embed the keyboard.html inside a simple Blocks app ---
|
| 99 |
-
with gr.Blocks() as demo:
|
| 100 |
-
gr.HTML(
|
| 101 |
-
'<iframe src="/keyboard" style="width:100%;height:420px;border:0;"></iframe>'
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
# Mount Gradio under /app so FastAPI root keeps /save_midi endpoint available
|
| 105 |
-
# If you change the path, update the client fetch URL
|
| 106 |
-
gr.mount_gradio_app(app, demo, path="/app")
|
| 107 |
-
|
| 108 |
-
# If you want to run this file directly (uvicorn recommended), nothing else to add.
|
| 109 |
-
# Run: uvicorn main:app --reload --port 8000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Static Assets
|
| 2 |
+
|
| 3 |
+
This directory contains the client-side assets for the Virtual MIDI Keyboard.
|
| 4 |
+
|
| 5 |
+
## Files
|
| 6 |
+
|
| 7 |
+
- **styles.css** - All application styles including keyboard, controls, and MIDI terminal
|
| 8 |
+
- **keyboard.js** - Client-side logic for:
|
| 9 |
+
- Keyboard rendering and layout
|
| 10 |
+
- Audio synthesis (Tone.js integration)
|
| 11 |
+
- MIDI event recording
|
| 12 |
+
- Computer keyboard input handling
|
| 13 |
+
- MIDI monitor/terminal
|
| 14 |
+
- File export functionality
|
| 15 |
+
|
| 16 |
+
## Usage
|
| 17 |
+
|
| 18 |
+
These files are loaded by `keyboard.html` via `/file=` paths in Gradio.
|
static/keyboard.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Virtual MIDI Keyboard - Main JavaScript
|
| 3 |
+
*
|
| 4 |
+
* This file handles:
|
| 5 |
+
* - Keyboard rendering and layout
|
| 6 |
+
* - Audio synthesis (Tone.js)
|
| 7 |
+
* - MIDI event recording
|
| 8 |
+
* - Computer keyboard input
|
| 9 |
+
* - MIDI monitor/terminal
|
| 10 |
+
* - File export
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
// =============================================================================
|
| 14 |
+
// CONFIGURATION
|
| 15 |
+
// =============================================================================
|
| 16 |
+
|
| 17 |
+
const baseMidi = 60; // C4
|
| 18 |
+
const numOctaves = 2;
|
| 19 |
+
|
| 20 |
+
// Keyboard layout with sharps flagged
|
| 21 |
+
const keys = [
|
| 22 |
+
{name:'C', offset:0, black:false},
|
| 23 |
+
{name:'C#', offset:1, black:true},
|
| 24 |
+
{name:'D', offset:2, black:false},
|
| 25 |
+
{name:'D#', offset:3, black:true},
|
| 26 |
+
{name:'E', offset:4, black:false},
|
| 27 |
+
{name:'F', offset:5, black:false},
|
| 28 |
+
{name:'F#', offset:6, black:true},
|
| 29 |
+
{name:'G', offset:7, black:false},
|
| 30 |
+
{name:'G#', offset:8, black:true},
|
| 31 |
+
{name:'A', offset:9, black:false},
|
| 32 |
+
{name:'A#', offset:10, black:true},
|
| 33 |
+
{name:'B', offset:11, black:false}
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
// Computer keyboard mapping (C4 octave)
|
| 37 |
+
const keyMap = {
|
| 38 |
+
'a': 60, // C4
|
| 39 |
+
'w': 61, // C#4
|
| 40 |
+
's': 62, // D4
|
| 41 |
+
'e': 63, // D#4
|
| 42 |
+
'd': 64, // E4
|
| 43 |
+
'f': 65, // F4
|
| 44 |
+
't': 66, // F#4
|
| 45 |
+
'g': 67, // G4
|
| 46 |
+
'y': 68, // G#4
|
| 47 |
+
'h': 69, // A4
|
| 48 |
+
'u': 70, // A#4
|
| 49 |
+
'j': 71 // B4
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Keyboard shortcuts displayed on keys
|
| 53 |
+
const keyShortcuts = {
|
| 54 |
+
60: 'A', 61: 'W', 62: 'S', 63: 'E', 64: 'D', 65: 'F',
|
| 55 |
+
66: 'T', 67: 'G', 68: 'Y', 69: 'H', 70: 'U', 71: 'J'
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
// =============================================================================
|
| 59 |
+
// DOM ELEMENTS
|
| 60 |
+
// =============================================================================
|
| 61 |
+
|
| 62 |
+
const keyboardEl = document.getElementById('keyboard');
|
| 63 |
+
const statusEl = document.getElementById('status');
|
| 64 |
+
const recordBtn = document.getElementById('recordBtn');
|
| 65 |
+
const stopBtn = document.getElementById('stopBtn');
|
| 66 |
+
const saveBtn = document.getElementById('saveBtn');
|
| 67 |
+
const keyboardToggle = document.getElementById('keyboardToggle');
|
| 68 |
+
const instrumentSelect = document.getElementById('instrumentSelect');
|
| 69 |
+
const terminal = document.getElementById('terminal');
|
| 70 |
+
const clearTerminal = document.getElementById('clearTerminal');
|
| 71 |
+
|
| 72 |
+
// =============================================================================
|
| 73 |
+
// STATE
|
| 74 |
+
// =============================================================================
|
| 75 |
+
|
| 76 |
+
let synth = null;
|
| 77 |
+
let recording = false;
|
| 78 |
+
let startTime = 0;
|
| 79 |
+
let events = [];
|
| 80 |
+
const pressedKeys = new Set();
|
| 81 |
+
|
| 82 |
+
// =============================================================================
|
| 83 |
+
// INSTRUMENT CONFIGURATIONS
|
| 84 |
+
// =============================================================================
|
| 85 |
+
|
| 86 |
+
const instruments = {
|
| 87 |
+
synth: () => new Tone.PolySynth(Tone.Synth, {
|
| 88 |
+
oscillator: { type: 'sine' },
|
| 89 |
+
envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1 }
|
| 90 |
+
}).toDestination(),
|
| 91 |
+
|
| 92 |
+
piano: () => new Tone.PolySynth(Tone.Synth, {
|
| 93 |
+
oscillator: { type: 'triangle' },
|
| 94 |
+
envelope: { attack: 0.001, decay: 0.2, sustain: 0.1, release: 2 }
|
| 95 |
+
}).toDestination(),
|
| 96 |
+
|
| 97 |
+
organ: () => new Tone.PolySynth(Tone.Synth, {
|
| 98 |
+
oscillator: { type: 'sine4' },
|
| 99 |
+
envelope: { attack: 0.001, decay: 0, sustain: 1, release: 0.1 }
|
| 100 |
+
}).toDestination(),
|
| 101 |
+
|
| 102 |
+
bass: () => new Tone.PolySynth(Tone.Synth, {
|
| 103 |
+
oscillator: { type: 'sawtooth' },
|
| 104 |
+
envelope: { attack: 0.01, decay: 0.1, sustain: 0.4, release: 1.5 },
|
| 105 |
+
filter: { Q: 2, type: 'lowpass', rolloff: -12 }
|
| 106 |
+
}).toDestination(),
|
| 107 |
+
|
| 108 |
+
pluck: () => new Tone.PolySynth(Tone.Synth, {
|
| 109 |
+
oscillator: { type: 'triangle' },
|
| 110 |
+
envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.3 }
|
| 111 |
+
}).toDestination(),
|
| 112 |
+
|
| 113 |
+
fm: () => new Tone.PolySynth(Tone.FMSynth, {
|
| 114 |
+
harmonicity: 3,
|
| 115 |
+
modulationIndex: 10,
|
| 116 |
+
envelope: { attack: 0.01, decay: 0.2, sustain: 0.2, release: 1 }
|
| 117 |
+
}).toDestination()
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
function loadInstrument(type) {
|
| 121 |
+
if (synth) {
|
| 122 |
+
synth.releaseAll();
|
| 123 |
+
synth.dispose();
|
| 124 |
+
}
|
| 125 |
+
synth = instruments[type]();
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// =============================================================================
|
| 129 |
+
// KEYBOARD RENDERING
|
| 130 |
+
// =============================================================================
|
| 131 |
+
|
| 132 |
+
function buildKeyboard() {
|
| 133 |
+
for (let octave = 0; octave < numOctaves; octave++) {
|
| 134 |
+
for (let i = 0; i < keys.length; i++) {
|
| 135 |
+
const k = keys[i];
|
| 136 |
+
const midiNote = baseMidi + (octave * 12) + k.offset;
|
| 137 |
+
const octaveNum = 4 + octave;
|
| 138 |
+
const keyEl = document.createElement('div');
|
| 139 |
+
keyEl.className = 'key' + (k.black ? ' black' : '');
|
| 140 |
+
keyEl.dataset.midi = midiNote;
|
| 141 |
+
|
| 142 |
+
const shortcut = keyShortcuts[midiNote] || '';
|
| 143 |
+
const shortcutHtml = shortcut ? `<div style="font-size:10px;opacity:0.5;">${shortcut}</div>` : '';
|
| 144 |
+
keyEl.innerHTML = `<div style="padding-bottom:6px;font-size:11px">${shortcutHtml}${k.name}${octaveNum}</div>`;
|
| 145 |
+
|
| 146 |
+
keyboardEl.appendChild(keyEl);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// =============================================================================
|
| 152 |
+
// MIDI UTILITIES
|
| 153 |
+
// =============================================================================
|
| 154 |
+
|
| 155 |
+
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
| 156 |
+
|
| 157 |
+
function midiToNoteName(midi) {
|
| 158 |
+
const octave = Math.floor(midi / 12) - 1;
|
| 159 |
+
const noteName = noteNames[midi % 12];
|
| 160 |
+
return `${noteName}${octave}`;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function nowSec() {
|
| 164 |
+
return performance.now() / 1000;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// =============================================================================
|
| 168 |
+
// TERMINAL LOGGING
|
| 169 |
+
// =============================================================================
|
| 170 |
+
|
| 171 |
+
function logToTerminal(message, className = '') {
|
| 172 |
+
const line = document.createElement('div');
|
| 173 |
+
line.className = className;
|
| 174 |
+
line.textContent = message;
|
| 175 |
+
terminal.appendChild(line);
|
| 176 |
+
terminal.scrollTop = terminal.scrollHeight;
|
| 177 |
+
|
| 178 |
+
// Keep terminal from getting too long (max 500 lines)
|
| 179 |
+
while (terminal.children.length > 500) {
|
| 180 |
+
terminal.removeChild(terminal.firstChild);
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function initTerminal() {
|
| 185 |
+
logToTerminal('=== MIDI Monitor Ready ===', 'timestamp');
|
| 186 |
+
logToTerminal('Play notes to see MIDI events...', 'timestamp');
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// =============================================================================
|
| 190 |
+
// RECORDING
|
| 191 |
+
// =============================================================================
|
| 192 |
+
|
| 193 |
+
function beginRecord() {
|
| 194 |
+
events = [];
|
| 195 |
+
recording = true;
|
| 196 |
+
startTime = nowSec();
|
| 197 |
+
statusEl.textContent = 'Recording...';
|
| 198 |
+
recordBtn.disabled = true;
|
| 199 |
+
stopBtn.disabled = false;
|
| 200 |
+
saveBtn.disabled = true;
|
| 201 |
+
logToTerminal('\n=== RECORDING STARTED ===', 'timestamp');
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function stopRecord() {
|
| 205 |
+
recording = false;
|
| 206 |
+
statusEl.textContent = `Recorded ${events.length} events`;
|
| 207 |
+
recordBtn.disabled = false;
|
| 208 |
+
stopBtn.disabled = true;
|
| 209 |
+
saveBtn.disabled = events.length === 0;
|
| 210 |
+
logToTerminal(`=== RECORDING STOPPED (${events.length} events) ===\n`, 'timestamp');
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// =============================================================================
|
| 214 |
+
// MIDI NOTE HANDLING
|
| 215 |
+
// =============================================================================
|
| 216 |
+
|
| 217 |
+
function noteOn(midiNote, velocity = 100) {
|
| 218 |
+
const freq = Tone.Frequency(midiNote, "midi").toFrequency();
|
| 219 |
+
synth.triggerAttack(freq, undefined, velocity / 127);
|
| 220 |
+
|
| 221 |
+
const noteName = midiToNoteName(midiNote);
|
| 222 |
+
const timestamp = recording ? (nowSec() - startTime).toFixed(3) : '--';
|
| 223 |
+
logToTerminal(
|
| 224 |
+
`[${timestamp}s] NOTE_ON ${noteName} (${midiNote}) vel=${velocity}`,
|
| 225 |
+
'note-on'
|
| 226 |
+
);
|
| 227 |
+
|
| 228 |
+
if (recording) {
|
| 229 |
+
events.push({
|
| 230 |
+
type: 'note_on',
|
| 231 |
+
note: midiNote,
|
| 232 |
+
velocity: Math.max(1, velocity | 0),
|
| 233 |
+
time: nowSec() - startTime,
|
| 234 |
+
channel: 0
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function noteOff(midiNote) {
|
| 240 |
+
const freq = Tone.Frequency(midiNote, "midi").toFrequency();
|
| 241 |
+
synth.triggerRelease(freq);
|
| 242 |
+
|
| 243 |
+
const noteName = midiToNoteName(midiNote);
|
| 244 |
+
const timestamp = recording ? (nowSec() - startTime).toFixed(3) : '--';
|
| 245 |
+
logToTerminal(
|
| 246 |
+
`[${timestamp}s] NOTE_OFF ${noteName} (${midiNote})`,
|
| 247 |
+
'note-off'
|
| 248 |
+
);
|
| 249 |
+
|
| 250 |
+
if (recording) {
|
| 251 |
+
events.push({
|
| 252 |
+
type: 'note_off',
|
| 253 |
+
note: midiNote,
|
| 254 |
+
velocity: 0,
|
| 255 |
+
time: nowSec() - startTime,
|
| 256 |
+
channel: 0
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// =============================================================================
|
| 262 |
+
// COMPUTER KEYBOARD INPUT
|
| 263 |
+
// =============================================================================
|
| 264 |
+
|
| 265 |
+
function getKeyElement(midiNote) {
|
| 266 |
+
return keyboardEl.querySelector(`.key[data-midi="${midiNote}"]`);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
document.addEventListener('keydown', async (ev) => {
|
| 270 |
+
if (!keyboardToggle.checked) return;
|
| 271 |
+
const key = ev.key.toLowerCase();
|
| 272 |
+
if (!keyMap[key] || pressedKeys.has(key)) return;
|
| 273 |
+
|
| 274 |
+
ev.preventDefault();
|
| 275 |
+
pressedKeys.add(key);
|
| 276 |
+
|
| 277 |
+
await Tone.start();
|
| 278 |
+
|
| 279 |
+
const midiNote = keyMap[key];
|
| 280 |
+
const keyEl = getKeyElement(midiNote);
|
| 281 |
+
if (keyEl) keyEl.style.filter = 'brightness(0.85)';
|
| 282 |
+
noteOn(midiNote, 100);
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
document.addEventListener('keyup', (ev) => {
|
| 286 |
+
if (!keyboardToggle.checked) return;
|
| 287 |
+
const key = ev.key.toLowerCase();
|
| 288 |
+
if (!keyMap[key] || !pressedKeys.has(key)) return;
|
| 289 |
+
|
| 290 |
+
ev.preventDefault();
|
| 291 |
+
pressedKeys.delete(key);
|
| 292 |
+
|
| 293 |
+
const midiNote = keyMap[key];
|
| 294 |
+
const keyEl = getKeyElement(midiNote);
|
| 295 |
+
if (keyEl) keyEl.style.filter = '';
|
| 296 |
+
noteOff(midiNote);
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
// =============================================================================
|
| 300 |
+
// MOUSE/TOUCH INPUT
|
| 301 |
+
// =============================================================================
|
| 302 |
+
|
| 303 |
+
function attachPointerEvents() {
|
| 304 |
+
keyboardEl.querySelectorAll('.key').forEach(k => {
|
| 305 |
+
let pressed = false;
|
| 306 |
+
|
| 307 |
+
k.addEventListener('pointerdown', (ev) => {
|
| 308 |
+
ev.preventDefault();
|
| 309 |
+
k.setPointerCapture(ev.pointerId);
|
| 310 |
+
if (!pressed) {
|
| 311 |
+
pressed = true;
|
| 312 |
+
k.style.filter = 'brightness(0.85)';
|
| 313 |
+
const midi = parseInt(k.dataset.midi);
|
| 314 |
+
const vel = ev.pressure ? Math.round(ev.pressure * 127) : 100;
|
| 315 |
+
noteOn(midi, vel);
|
| 316 |
+
}
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
k.addEventListener('pointerup', (ev) => {
|
| 320 |
+
ev.preventDefault();
|
| 321 |
+
if (pressed) {
|
| 322 |
+
pressed = false;
|
| 323 |
+
k.style.filter = '';
|
| 324 |
+
const midi = parseInt(k.dataset.midi);
|
| 325 |
+
noteOff(midi);
|
| 326 |
+
}
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
k.addEventListener('pointerleave', (ev) => {
|
| 330 |
+
if (pressed) {
|
| 331 |
+
pressed = false;
|
| 332 |
+
k.style.filter = '';
|
| 333 |
+
const midi = parseInt(k.dataset.midi);
|
| 334 |
+
noteOff(midi);
|
| 335 |
+
}
|
| 336 |
+
});
|
| 337 |
+
});
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// =============================================================================
|
| 341 |
+
// MIDI FILE EXPORT
|
| 342 |
+
// =============================================================================
|
| 343 |
+
|
| 344 |
+
async function saveMIDI() {
|
| 345 |
+
if (recording) stopRecord();
|
| 346 |
+
if (events.length === 0) return alert('No events recorded.');
|
| 347 |
+
|
| 348 |
+
statusEl.textContent = 'Uploading…';
|
| 349 |
+
saveBtn.disabled = true;
|
| 350 |
+
|
| 351 |
+
try {
|
| 352 |
+
const startResp = await fetch('/gradio_api/call/save_midi', {
|
| 353 |
+
method: 'POST',
|
| 354 |
+
headers: {'Content-Type':'application/json'},
|
| 355 |
+
body: JSON.stringify({data: [events]})
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
if (!startResp.ok) {
|
| 359 |
+
const txt = await startResp.text();
|
| 360 |
+
throw new Error('Server error: ' + txt);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
const startJson = await startResp.json();
|
| 364 |
+
if (!startJson || !startJson.event_id) {
|
| 365 |
+
throw new Error('Invalid API response');
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const resultResp = await fetch(`/gradio_api/call/save_midi/${startJson.event_id}`);
|
| 369 |
+
if (!resultResp.ok) {
|
| 370 |
+
const txt = await resultResp.text();
|
| 371 |
+
throw new Error('Server error: ' + txt);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
const resultText = await resultResp.text();
|
| 375 |
+
const dataLine = resultText.split('\n').find(line => line.startsWith('data:'));
|
| 376 |
+
if (!dataLine) {
|
| 377 |
+
throw new Error('Invalid API response');
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const payloadList = JSON.parse(dataLine.replace('data:', '').trim());
|
| 381 |
+
const payload = Array.isArray(payloadList) ? payloadList[0] : null;
|
| 382 |
+
if (!payload || payload.error || !payload.midi_base64) {
|
| 383 |
+
throw new Error(payload && payload.error ? payload.error : 'Invalid API response');
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const binStr = atob(payload.midi_base64);
|
| 387 |
+
const bytes = new Uint8Array(binStr.length);
|
| 388 |
+
for (let i = 0; i < binStr.length; i++) {
|
| 389 |
+
bytes[i] = binStr.charCodeAt(i);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
const blob = new Blob([bytes], {type: 'audio/midi'});
|
| 393 |
+
const url = URL.createObjectURL(blob);
|
| 394 |
+
const a = document.createElement('a');
|
| 395 |
+
a.href = url;
|
| 396 |
+
a.download = 'recording.mid';
|
| 397 |
+
a.click();
|
| 398 |
+
statusEl.textContent = 'Downloaded .mid';
|
| 399 |
+
} catch (err) {
|
| 400 |
+
console.error(err);
|
| 401 |
+
statusEl.textContent = 'Error saving MIDI';
|
| 402 |
+
alert('Error: ' + err.message);
|
| 403 |
+
} finally {
|
| 404 |
+
saveBtn.disabled = false;
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// =============================================================================
|
| 409 |
+
// EVENT LISTENERS
|
| 410 |
+
// =============================================================================
|
| 411 |
+
|
| 412 |
+
instrumentSelect.addEventListener('change', () => {
|
| 413 |
+
loadInstrument(instrumentSelect.value);
|
| 414 |
+
});
|
| 415 |
+
|
| 416 |
+
keyboardToggle.addEventListener('change', () => {
|
| 417 |
+
if (!keyboardToggle.checked) {
|
| 418 |
+
// Release all currently pressed keyboard keys
|
| 419 |
+
pressedKeys.forEach(key => {
|
| 420 |
+
const midiNote = keyMap[key];
|
| 421 |
+
const keyEl = getKeyElement(midiNote);
|
| 422 |
+
if (keyEl) keyEl.style.filter = '';
|
| 423 |
+
noteOff(midiNote);
|
| 424 |
+
});
|
| 425 |
+
pressedKeys.clear();
|
| 426 |
+
}
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
clearTerminal.addEventListener('click', () => {
|
| 430 |
+
terminal.innerHTML = '';
|
| 431 |
+
logToTerminal('=== MIDI Monitor Ready ===', 'timestamp');
|
| 432 |
+
});
|
| 433 |
+
|
| 434 |
+
recordBtn.addEventListener('click', async () => {
|
| 435 |
+
await Tone.start();
|
| 436 |
+
beginRecord();
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
+
stopBtn.addEventListener('click', () => stopRecord());
|
| 440 |
+
|
| 441 |
+
saveBtn.addEventListener('click', () => saveMIDI());
|
| 442 |
+
|
| 443 |
+
// =============================================================================
|
| 444 |
+
// INITIALIZATION
|
| 445 |
+
// =============================================================================
|
| 446 |
+
|
| 447 |
+
function init() {
|
| 448 |
+
loadInstrument('synth');
|
| 449 |
+
buildKeyboard();
|
| 450 |
+
attachPointerEvents();
|
| 451 |
+
initTerminal();
|
| 452 |
+
|
| 453 |
+
// Set initial button states
|
| 454 |
+
recordBtn.disabled = false;
|
| 455 |
+
stopBtn.disabled = true;
|
| 456 |
+
saveBtn.disabled = true;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Start the application
|
| 460 |
+
init();
|
static/styles.css
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Virtual MIDI Keyboard - Styles */
|
| 2 |
+
|
| 3 |
+
/* Layout */
|
| 4 |
+
body {
|
| 5 |
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
| 6 |
+
padding: 12px;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
#mainContainer {
|
| 10 |
+
display: flex;
|
| 11 |
+
gap: 20px;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#leftPanel {
|
| 15 |
+
flex: 0 0 auto;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
#rightPanel {
|
| 19 |
+
flex: 1;
|
| 20 |
+
min-width: 300px;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* Keyboard */
|
| 24 |
+
#keyboard {
|
| 25 |
+
display: flex;
|
| 26 |
+
user-select: none;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.key {
|
| 30 |
+
width: 42px;
|
| 31 |
+
height: 180px;
|
| 32 |
+
border: 1px solid #333;
|
| 33 |
+
margin: 0 1px;
|
| 34 |
+
background: white;
|
| 35 |
+
position: relative;
|
| 36 |
+
display: flex;
|
| 37 |
+
align-items: flex-end;
|
| 38 |
+
justify-content: center;
|
| 39 |
+
cursor: pointer;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.key.black {
|
| 43 |
+
width: 30px;
|
| 44 |
+
height: 110px;
|
| 45 |
+
background: #222;
|
| 46 |
+
color: white;
|
| 47 |
+
margin-left: -15px;
|
| 48 |
+
margin-right: -15px;
|
| 49 |
+
z-index: 2;
|
| 50 |
+
position: relative;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* Controls */
|
| 54 |
+
.controls {
|
| 55 |
+
margin-top: 10px;
|
| 56 |
+
display: flex;
|
| 57 |
+
gap: 8px;
|
| 58 |
+
align-items: center;
|
| 59 |
+
flex-wrap: wrap;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
button {
|
| 63 |
+
padding: 8px 12px;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
#status {
|
| 67 |
+
margin-left: 8px;
|
| 68 |
+
color: #666;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
#downloadLink {
|
| 72 |
+
display: inline-block;
|
| 73 |
+
margin-left: 8px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/* MIDI Terminal */
|
| 77 |
+
#terminal {
|
| 78 |
+
background: #1e1e1e;
|
| 79 |
+
color: #00ff00;
|
| 80 |
+
font-family: 'Courier New', monospace;
|
| 81 |
+
font-size: 12px;
|
| 82 |
+
padding: 12px;
|
| 83 |
+
height: 400px;
|
| 84 |
+
overflow-y: auto;
|
| 85 |
+
border: 1px solid #333;
|
| 86 |
+
border-radius: 4px;
|
| 87 |
+
white-space: pre-wrap;
|
| 88 |
+
word-wrap: break-word;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
#terminal .note-on {
|
| 92 |
+
color: #00ff00;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
#terminal .note-off {
|
| 96 |
+
color: #888;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
#terminal .timestamp {
|
| 100 |
+
color: #448;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.terminal-header {
|
| 104 |
+
display: flex;
|
| 105 |
+
justify-content: space-between;
|
| 106 |
+
align-items: center;
|
| 107 |
+
margin-bottom: 8px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.terminal-header button {
|
| 111 |
+
padding: 4px 8px;
|
| 112 |
+
font-size: 11px;
|
| 113 |
+
}
|