FJFehr commited on
Commit
5b51485
·
0 Parent(s):

initial commit

Browse files
Files changed (7) hide show
  1. .gitignore +75 -0
  2. README.md +47 -0
  3. app.py +95 -0
  4. keyboard.html +216 -0
  5. main.py +109 -0
  6. pyproject.toml +10 -0
  7. requirements.txt +2 -0
.gitignore ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+
29
+ # Virtual environments
30
+ .venv/
31
+ venv/
32
+ ENV/
33
+ env/
34
+
35
+ # UV lock file
36
+ uv.lock
37
+
38
+ # PyInstaller
39
+ *.manifest
40
+ *.spec
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+
56
+ # IDEs
57
+ .vscode/
58
+ .idea/
59
+ *.swp
60
+ *.swo
61
+ *~
62
+
63
+ # Jupyter Notebook
64
+ .ipynb_checkpoints
65
+
66
+ # Environment variables
67
+ .env
68
+ .env.local
69
+
70
+ # Logs
71
+ *.log
72
+
73
+ # OS files
74
+ .DS_Store
75
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual MIDI Keyboard
2
+
3
+ Minimal browser MIDI keyboard: play in the browser, record note events, export a .mid file.
4
+
5
+ ## Files
6
+
7
+ - app.py: Gradio app + MIDI export API
8
+ - keyboard.html: client-side keyboard (Tone.js)
9
+
10
+ ## Run locally
11
+
12
+ ```bash
13
+ uv venv
14
+ uv pip install -r requirements.txt
15
+ uv run python app.py
16
+ ```
17
+
18
+ Open http://127.0.0.1:7860
19
+
20
+ ## Deploy to Hugging Face Spaces (Gradio SDK)
21
+
22
+ Include these files at the repo root:
23
+
24
+ - app.py
25
+ - keyboard.html
26
+ - requirements.txt
27
+
28
+ Then create a Gradio Space and push the repo.
29
+
30
+ ## API
31
+
32
+ The browser posts events to the Gradio call endpoint:
33
+
34
+ ```
35
+ POST /gradio_api/call/save_midi
36
+ {
37
+ "data": [events]
38
+ }
39
+ ```
40
+
41
+ The response returns an event_id. Fetch the result from:
42
+
43
+ ```
44
+ GET /gradio_api/call/save_midi/{event_id}
45
+ ```
46
+
47
+ The response includes base64 MIDI data at data[0].midi_base64.
app.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import html
3
+ import io
4
+
5
+ import mido
6
+ from mido import MidiFile, MidiTrack, Message, MetaMessage
7
+
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"}
65
+
66
+ mid_bytes = events_to_midbytes(events)
67
+ midi_b64 = base64.b64encode(mid_bytes).decode("ascii")
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()
keyboard.html ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
main.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
pyproject.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "virtual-keyboard"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "gradio>=6.5.1",
9
+ "mido>=1.3.3",
10
+ ]
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio
2
+ mido