FJFehr commited on
Commit
c427c8f
·
1 Parent(s): 1e69b92

2 octive keys, keyboard utility, terminal for visuals and refactor for simplicity

Browse files
Files changed (8) hide show
  1. .gitignore +1 -0
  2. README.md +25 -0
  3. app.py +92 -26
  4. keyboard.html +48 -205
  5. main.py +0 -109
  6. static/README.md +18 -0
  7. static/keyboard.js +460 -0
  8. 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
- # --- MIDI conversion helper ---
 
 
 
 
12
  def events_to_midbytes(events, ticks_per_beat=480, tempo_bpm=120):
13
  """
14
- events: list of {type:'note_on'|'note_off', note:int, velocity:int, time:float (seconds), channel:int}
15
- returns: bytes of a .mid file
 
 
 
 
 
 
 
16
  """
17
  mid = MidiFile(ticks_per_beat=ticks_per_beat)
18
  track = MidiTrack()
19
  mid.tracks.append(track)
20
- # set tempo meta message
 
21
  tempo = mido.bpm2tempo(tempo_bpm)
22
  track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
23
 
24
- # sort by absolute time
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
- # safety: skip malformed
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
- # ticks = seconds * ticks_per_beat * bpm / 60
35
- ticks = int(round(dt_sec * (ticks_per_beat * (tempo_bpm)) / 60.0))
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
- # write to bytes
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: events is a list of MIDI event dicts from the browser.
61
- Returns: JSON with base64-encoded MIDI bytes.
 
 
 
 
 
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
- with open("keyboard.html", "r", encoding="utf-8") as handle:
72
- keyboard_html = handle.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  iframe_html = (
75
  '<iframe srcdoc="'
76
  + html.escape(keyboard_html, quote=True)
77
- + '" style="width:100%;height:420px;border:0;"></iframe>'
78
  )
79
 
80
- # --- Gradio UI: embed the keyboard HTML in an iframe and expose an API ---
81
  with gr.Blocks() as demo:
82
  gr.HTML(iframe_html)
83
- api_input = gr.JSON(visible=False)
84
- api_output = gr.JSON(visible=False)
85
- api_trigger = gr.Button(visible=False)
86
- api_trigger.click(
87
- save_midi_api,
88
- inputs=api_input,
89
- outputs=api_output,
90
- api_name="save_midi",
 
 
91
  )
92
 
93
- # Run locally: python app.py
 
 
 
 
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 (Gradio Prototype)</title>
6
- <meta name="viewport" content="width=device-width,initial-scale=1" />
7
- <style>
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
- <div id="keyboard"></div>
21
-
22
- <div class="controls">
23
- <button id="recordBtn">Record</button>
24
- <button id="stopBtn" disabled>Stop</button>
25
- <button id="saveBtn" disabled>Save MIDI</button>
26
- <span id="status">Idle</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  </div>
28
 
 
29
  <script src="https://unpkg.com/tone@next/build/Tone.js"></script>
30
- <script>
31
- // --- keyboard layout: C4..B4 (one octave) ---
32
- const baseMidi = 60; // C4
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
+ }