FJFehr commited on
Commit
055747e
·
1 Parent(s): 43a0d3f

Full refactoring and design of the repo

Browse files
Files changed (8) hide show
  1. app.py +50 -184
  2. config.py +142 -0
  3. engines.py +15 -92
  4. keyboard.html +1 -5
  5. midi.py +86 -0
  6. static/engine.js +0 -242
  7. static/keyboard.js +104 -54
  8. static/styles.css +0 -8
app.py CHANGED
@@ -1,111 +1,30 @@
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
- - Process recordings through various engines
8
  - Export recordings as .mid files
9
  - Support computer keyboard input
10
  - Monitor MIDI events in real-time
11
-
12
- File Structure:
13
- - app.py: Gradio server and MIDI conversion (this file)
14
- - engines.py: MIDI processing engines
15
- - keyboard.html: Main UI structure
16
- - static/styles.css: All application styles
17
- - static/keyboard.js: Client-side logic and interactivity
18
- - static/engine.js: Client-side engine abstraction
19
  """
20
 
21
  import base64
22
  import html
23
- import io
24
- import json
25
-
26
- import mido
27
- from mido import MidiFile, MidiTrack, Message, MetaMessage
28
-
29
  import gradio as gr
30
- from engines import EngineRegistry, ParrotEngine
31
-
32
-
33
- # =============================================================================
34
- # MIDI CONVERSION
35
- # =============================================================================
36
-
37
-
38
- def events_to_midbytes(events, ticks_per_beat=480, tempo_bpm=120):
39
- """
40
- Convert browser MIDI events to a .mid file format.
41
-
42
- Args:
43
- events: List of dicts with {type, note, velocity, time, channel}
44
- ticks_per_beat: MIDI ticks per beat resolution (default: 480)
45
- tempo_bpm: Tempo in beats per minute (default: 120)
46
-
47
- Returns:
48
- bytes: Complete MIDI file as bytes
49
- """
50
- mid = MidiFile(ticks_per_beat=ticks_per_beat)
51
- track = MidiTrack()
52
- mid.tracks.append(track)
53
 
54
- # Set tempo meta message
55
- tempo = mido.bpm2tempo(tempo_bpm)
56
- track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
57
-
58
- # Sort events by time and convert to MIDI messages
59
- evs = sorted(events, key=lambda e: e.get("time", 0.0))
60
- last_time = 0.0
61
- for ev in evs:
62
- # Skip malformed events
63
- if "time" not in ev or "type" not in ev or "note" not in ev:
64
- continue
65
-
66
- # Calculate delta time in ticks
67
- dt_sec = max(0.0, ev["time"] - last_time)
68
- last_time = ev["time"]
69
- ticks = int(round(dt_sec * (ticks_per_beat * tempo_bpm) / 60.0))
70
- # Create MIDI message
71
- ev_type = ev["type"]
72
- note = int(ev["note"])
73
- vel = int(ev.get("velocity", 0))
74
- channel = int(ev.get("channel", 0))
75
-
76
- if ev_type == "note_on":
77
- msg = Message(
78
- "note_on", note=note, velocity=vel, time=ticks, channel=channel
79
- )
80
- else:
81
- msg = Message(
82
- "note_off", note=note, velocity=vel, time=ticks, channel=channel
83
- )
84
-
85
- track.append(msg)
86
-
87
- # Write to bytes buffer
88
- buf = io.BytesIO()
89
- mid.save(file=buf)
90
- buf.seek(0)
91
- return buf.read()
92
 
93
 
94
  # =============================================================================
95
- # API ENDPOINT
96
  # =============================================================================
97
 
98
 
99
  def save_midi_api(events):
100
- """
101
- Gradio API endpoint for converting recorded events to MIDI file.
102
-
103
- Args:
104
- events: List of MIDI event dictionaries from the browser
105
-
106
- Returns:
107
- Dict with 'midi_base64' or 'error' key
108
- """
109
  if not isinstance(events, list) or len(events) == 0:
110
  return {"error": "No events provided"}
111
 
@@ -114,33 +33,21 @@ def save_midi_api(events):
114
  return {"midi_base64": midi_b64}
115
 
116
 
117
- # =============================================================================
118
- # ENGINE API ENDPOINTS
119
- # =============================================================================
120
-
121
-
122
- def list_engines():
123
- """
124
- API endpoint to list available MIDI engines
125
-
126
- Returns:
127
- List of engine info dictionaries
128
- """
129
- engines = EngineRegistry.list_engines()
130
- return {"engines": [EngineRegistry.get_engine_info(e) for e in engines]}
131
 
132
 
133
  def process_with_engine(engine_id: str, events: list):
134
- """
135
- API endpoint to process MIDI events through an engine
136
-
137
- Args:
138
- engine_id: The engine to use (e.g. 'parrot')
139
- events: List of MIDI event dictionaries
140
-
141
- Returns:
142
- Processed events or error message
143
- """
144
  if not engine_id or not events:
145
  return {"error": "Missing engine_id or events"}
146
 
@@ -154,59 +61,11 @@ def process_with_engine(engine_id: str, events: list):
154
  return {"error": f"Processing error: {str(e)}"}
155
 
156
 
157
- def process_engine_api(payload: dict):
158
- """
159
- Wrapper API endpoint that accepts JSON payload
160
-
161
- Args:
162
- payload: Dict with 'engine_id' and 'events' keys
163
-
164
- Returns:
165
- Processed events or error message
166
- """
167
- try:
168
- print(f"[DEBUG] process_engine_api called with payload type: {type(payload)}")
169
- print(
170
- f"[DEBUG] payload keys: {payload.keys() if isinstance(payload, dict) else 'N/A'}"
171
- )
172
-
173
- # Handle both direct dict and wrapped dict formats
174
- data = payload
175
- if isinstance(payload, dict) and "data" in payload:
176
- # If wrapped in a data field, unwrap it
177
- data = payload["data"]
178
- if isinstance(data, list) and len(data) > 0:
179
- data = data[0]
180
-
181
- print(
182
- f"[DEBUG] extracted data type: {type(data)}, has engine_id: {'engine_id' in data if isinstance(data, dict) else False}"
183
- )
184
-
185
- engine_id = data.get("engine_id") if isinstance(data, dict) else None
186
- events = data.get("events", []) if isinstance(data, dict) else []
187
-
188
- print(
189
- f"[DEBUG] engine_id: {engine_id}, events count: {len(events) if isinstance(events, list) else 'N/A'}"
190
- )
191
-
192
- result = process_with_engine(engine_id, events)
193
- print(
194
- f"[DEBUG] process_engine_api returning: {result.keys() if isinstance(result, dict) else type(result)}"
195
- )
196
- return result
197
- except Exception as e:
198
- print(f"[DEBUG] Exception in process_engine_api: {str(e)}")
199
- import traceback
200
-
201
- traceback.print_exc()
202
- return {"error": f"API error: {str(e)}"}
203
-
204
-
205
  # =============================================================================
206
- # GRADIO APPLICATION
207
  # =============================================================================
208
 
209
- # Load and combine HTML, CSS, and JS for the iframe
210
  with open("keyboard.html", "r", encoding="utf-8") as f:
211
  html_content = f.read()
212
 
@@ -216,7 +75,7 @@ with open("static/styles.css", "r", encoding="utf-8") as f:
216
  with open("static/keyboard.js", "r", encoding="utf-8") as f:
217
  js_content = f.read()
218
 
219
- # Inject CSS and JS into the HTML
220
  keyboard_html = html_content.replace(
221
  '<link rel="stylesheet" href="/file=static/styles.css" />',
222
  f"<style>{css_content}</style>",
@@ -230,34 +89,41 @@ iframe_html = (
230
  + '" style="width:100%;height:750px;border:0;"></iframe>'
231
  )
232
 
233
- # Create Gradio interface
234
  with gr.Blocks(title="Virtual MIDI Keyboard") as demo:
235
  gr.HTML(iframe_html)
236
 
237
- # Hidden API endpoints using Gradio's function API
238
- with gr.Group(visible=False) as api_group:
239
- # Process engine endpoint
240
- engine_input = gr.Textbox(label="engine_payload")
241
- engine_output = gr.Textbox(label="engine_result")
242
-
243
- def call_engine_api(payload_json):
244
- """Wrapper to call engine API with JSON input"""
245
- import json
 
 
246
 
247
- try:
248
- payload = (
249
- json.loads(payload_json)
250
- if isinstance(payload_json, str)
251
- else payload_json
252
- )
253
- result = process_engine_api(payload)
254
- return json.dumps(result)
255
- except Exception as e:
256
- return json.dumps({"error": str(e)})
257
 
 
 
 
258
  engine_btn = gr.Button("process_engine", visible=False)
259
  engine_btn.click(
260
- fn=call_engine_api,
 
 
261
  inputs=engine_input,
262
  outputs=engine_output,
263
  api_name="process_engine",
 
1
  """
2
  Virtual MIDI Keyboard - Gradio Application
3
 
4
+ A browser-based MIDI keyboard that can:
5
+ - Play notes with various synthesizer sounds
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
 
12
  import base64
13
  import html
 
 
 
 
 
 
14
  import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ from config import INSTRUMENTS, KEYBOARD_KEYS, KEYBOARD_SHORTCUTS
17
+ from midi import events_to_midbytes
18
+ from engines import EngineRegistry
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  # =============================================================================
22
+ # API ENDPOINTS
23
  # =============================================================================
24
 
25
 
26
  def save_midi_api(events):
27
+ """Export recorded MIDI events to .mid file"""
 
 
 
 
 
 
 
 
28
  if not isinstance(events, list) or len(events) == 0:
29
  return {"error": "No events provided"}
30
 
 
33
  return {"midi_base64": midi_b64}
34
 
35
 
36
+ def get_config():
37
+ """Provide frontend with instruments and keyboard layout"""
38
+ return {
39
+ "instruments": INSTRUMENTS,
40
+ "keyboard_keys": KEYBOARD_KEYS,
41
+ "keyboard_shortcuts": KEYBOARD_SHORTCUTS,
42
+ "engines": [
43
+ {"id": engine_id, "name": EngineRegistry.get_engine_info(engine_id)["name"]}
44
+ for engine_id in EngineRegistry.list_engines()
45
+ ],
46
+ }
 
 
 
47
 
48
 
49
  def process_with_engine(engine_id: str, events: list):
50
+ """Process MIDI events through selected engine"""
 
 
 
 
 
 
 
 
 
51
  if not engine_id or not events:
52
  return {"error": "Missing engine_id or events"}
53
 
 
61
  return {"error": f"Processing error: {str(e)}"}
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  # =============================================================================
65
+ # GRADIO UI
66
  # =============================================================================
67
 
68
+ # Load HTML and CSS
69
  with open("keyboard.html", "r", encoding="utf-8") as f:
70
  html_content = f.read()
71
 
 
75
  with open("static/keyboard.js", "r", encoding="utf-8") as f:
76
  js_content = f.read()
77
 
78
+ # Inject CSS and JS into HTML
79
  keyboard_html = html_content.replace(
80
  '<link rel="stylesheet" href="/file=static/styles.css" />',
81
  f"<style>{css_content}</style>",
 
89
  + '" style="width:100%;height:750px;border:0;"></iframe>'
90
  )
91
 
92
+ # Create Gradio app
93
  with gr.Blocks(title="Virtual MIDI Keyboard") as demo:
94
  gr.HTML(iframe_html)
95
 
96
+ # Hidden config API
97
+ with gr.Group(visible=False):
98
+ config_input = gr.Textbox(label="_")
99
+ config_output = gr.JSON(label="_")
100
+ config_btn = gr.Button("get_config", visible=False)
101
+ config_btn.click(
102
+ fn=lambda x: get_config(),
103
+ inputs=config_input,
104
+ outputs=config_output,
105
+ api_name="config",
106
+ )
107
 
108
+ # MIDI save API
109
+ midi_input = gr.JSON(label="_")
110
+ midi_output = gr.JSON(label="_")
111
+ midi_btn = gr.Button("save_midi", visible=False)
112
+ midi_btn.click(
113
+ fn=save_midi_api,
114
+ inputs=midi_input,
115
+ outputs=midi_output,
116
+ api_name="save_midi",
117
+ )
118
 
119
+ # Engine processing API
120
+ engine_input = gr.JSON(label="_")
121
+ engine_output = gr.JSON(label="_")
122
  engine_btn = gr.Button("process_engine", visible=False)
123
  engine_btn.click(
124
+ fn=lambda payload: process_with_engine(
125
+ payload.get("engine_id"), payload.get("events", [])
126
+ ),
127
  inputs=engine_input,
128
  outputs=engine_output,
129
  api_name="process_engine",
config.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Virtual MIDI Keyboard Configuration
3
+
4
+ Centralized configuration for instruments, keyboard layout, and defaults.
5
+ This is the single source of truth for all settings.
6
+ """
7
+
8
+ # =============================================================================
9
+ # KEYBOARD LAYOUT
10
+ # =============================================================================
11
+
12
+ KEYBOARD_BASE_MIDI = 60 # C4
13
+ KEYBOARD_OCTAVES = 2
14
+ KEYBOARD_POLYPHONY = 24
15
+
16
+ # Keyboard key layout (white keys first, then black keys in order)
17
+ KEYBOARD_KEYS = [
18
+ {"midi": 60, "name": "C4", "type": "white"},
19
+ {"midi": 61, "name": "C#4", "type": "black"},
20
+ {"midi": 62, "name": "D4", "type": "white"},
21
+ {"midi": 63, "name": "D#4", "type": "black"},
22
+ {"midi": 64, "name": "E4", "type": "white"},
23
+ {"midi": 65, "name": "F4", "type": "white"},
24
+ {"midi": 66, "name": "F#4", "type": "black"},
25
+ {"midi": 67, "name": "G4", "type": "white"},
26
+ {"midi": 68, "name": "G#4", "type": "black"},
27
+ {"midi": 69, "name": "A4", "type": "white"},
28
+ {"midi": 70, "name": "A#4", "type": "black"},
29
+ {"midi": 71, "name": "B4", "type": "white"},
30
+ {"midi": 72, "name": "C5", "type": "white"},
31
+ {"midi": 73, "name": "C#5", "type": "black"},
32
+ {"midi": 74, "name": "D5", "type": "white"},
33
+ {"midi": 75, "name": "D#5", "type": "black"},
34
+ {"midi": 76, "name": "E5", "type": "white"},
35
+ {"midi": 77, "name": "F5", "type": "white"},
36
+ {"midi": 78, "name": "F#5", "type": "black"},
37
+ {"midi": 79, "name": "G5", "type": "white"},
38
+ {"midi": 80, "name": "G#5", "type": "black"},
39
+ {"midi": 81, "name": "A5", "type": "white"},
40
+ {"midi": 82, "name": "A#5", "type": "black"},
41
+ {"midi": 83, "name": "B5", "type": "white"},
42
+ ]
43
+
44
+ # Computer keyboard shortcuts to MIDI notes
45
+ KEYBOARD_SHORTCUTS = {
46
+ 60: "A", # C4
47
+ 61: "W", # C#4
48
+ 62: "S", # D4
49
+ 63: "E", # D#4
50
+ 64: "D", # E4
51
+ 65: "F", # F4
52
+ 66: "T", # F#4
53
+ 67: "G", # G4
54
+ 68: "Y", # G#4
55
+ 69: "H", # A4
56
+ 70: "U", # A#4
57
+ 71: "J", # B4
58
+ }
59
+
60
+ # =============================================================================
61
+ # MIDI DEFAULTS
62
+ # =============================================================================
63
+
64
+ MIDI_DEFAULTS = {
65
+ "tempo_bpm": 120,
66
+ "ticks_per_beat": 480,
67
+ "velocity_default": 100,
68
+ }
69
+
70
+ # =============================================================================
71
+ # INSTRUMENTS
72
+ # =============================================================================
73
+
74
+ INSTRUMENTS = {
75
+ "synth": {
76
+ "name": "Synth",
77
+ "type": "Synth",
78
+ "oscillator": "sine",
79
+ "envelope": {
80
+ "attack": 0.005,
81
+ "decay": 0.1,
82
+ "sustain": 0.3,
83
+ "release": 1,
84
+ },
85
+ },
86
+ "piano": {
87
+ "name": "Piano",
88
+ "type": "Synth",
89
+ "oscillator": "triangle",
90
+ "envelope": {
91
+ "attack": 0.001,
92
+ "decay": 0.2,
93
+ "sustain": 0.1,
94
+ "release": 2,
95
+ },
96
+ },
97
+ "organ": {
98
+ "name": "Organ",
99
+ "type": "Synth",
100
+ "oscillator": "sine4",
101
+ "envelope": {
102
+ "attack": 0.001,
103
+ "decay": 0.0,
104
+ "sustain": 1.0,
105
+ "release": 0.1,
106
+ },
107
+ },
108
+ "bass": {
109
+ "name": "Bass",
110
+ "type": "Synth",
111
+ "oscillator": "sawtooth",
112
+ "envelope": {
113
+ "attack": 0.01,
114
+ "decay": 0.1,
115
+ "sustain": 0.4,
116
+ "release": 1.5,
117
+ },
118
+ },
119
+ "pluck": {
120
+ "name": "Pluck",
121
+ "type": "Synth",
122
+ "oscillator": "triangle",
123
+ "envelope": {
124
+ "attack": 0.001,
125
+ "decay": 0.3,
126
+ "sustain": 0.0,
127
+ "release": 0.3,
128
+ },
129
+ },
130
+ "fm": {
131
+ "name": "FM Synth",
132
+ "type": "FMSynth",
133
+ "harmonicity": 3,
134
+ "modulationIndex": 10,
135
+ "envelope": {
136
+ "attack": 0.01,
137
+ "decay": 0.2,
138
+ "sustain": 0.2,
139
+ "release": 1,
140
+ },
141
+ },
142
+ }
engines.py CHANGED
@@ -1,13 +1,11 @@
1
  """
2
  Virtual MIDI Keyboard - Engines
3
 
4
- This module contains MIDI processing engines that can transform,
5
- analyze, or manipulate MIDI events in various ways.
6
  """
7
 
8
  from abc import ABC, abstractmethod
9
- from typing import List, Dict, Any, Callable
10
- import time
11
 
12
 
13
  # =============================================================================
@@ -20,8 +18,6 @@ class MIDIEngine(ABC):
20
 
21
  def __init__(self, name: str):
22
  self.name = name
23
- self.recordings = {}
24
- self.current_recording_id = None
25
 
26
  @abstractmethod
27
  def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -36,99 +32,36 @@ class MIDIEngine(ABC):
36
  """
37
  pass
38
 
39
- def start_recording(self, recording_name: str = None) -> str:
40
- """Start recording a new performance"""
41
- if recording_name is None:
42
- recording_name = f"recording_{int(time.time() * 1000)}"
43
-
44
- self.current_recording_id = recording_name
45
- self.recordings[recording_name] = {
46
- "name": recording_name,
47
- "events": [],
48
- "created_at": time.time(),
49
- }
50
- return recording_name
51
-
52
- def stop_recording(self) -> Dict[str, Any]:
53
- """Stop recording and return the recording"""
54
- if not self.current_recording_id:
55
- return None
56
-
57
- recording_id = self.current_recording_id
58
- self.current_recording_id = None
59
- return self.recordings[recording_id]
60
-
61
- def add_event(self, event: Dict[str, Any]) -> bool:
62
- """Add event to current recording"""
63
- if not self.current_recording_id:
64
- return False
65
-
66
- recording = self.recordings[self.current_recording_id]
67
- recording["events"].append(event)
68
- return True
69
-
70
- def get_recording(self, recording_id: str) -> Dict[str, Any]:
71
- """Get a recording by ID"""
72
- return self.recordings.get(recording_id)
73
-
74
- def get_all_recordings(self) -> List[Dict[str, Any]]:
75
- """Get all recordings"""
76
- return list(self.recordings.values())
77
-
78
- def delete_recording(self, recording_id: str) -> bool:
79
- """Delete a recording"""
80
- if recording_id in self.recordings:
81
- if self.current_recording_id == recording_id:
82
- self.stop_recording()
83
- del self.recordings[recording_id]
84
- return True
85
- return False
86
-
87
 
88
  # =============================================================================
89
- # PARROT ENGINE - Returns exactly what was played
90
  # =============================================================================
91
 
92
 
93
  class ParrotEngine(MIDIEngine):
94
  """
95
- Parrot Engine - Captures and plays back MIDI exactly as recorded.
96
 
97
  This is the simplest engine - it just repeats what the user played.
98
- Perfect for learning and basic playback.
99
  """
100
 
101
  def __init__(self):
102
  super().__init__("Parrot")
103
- self.description = "Captures and plays back MIDI exactly as recorded"
104
 
105
  def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
106
- """
107
- Process MIDI events - in Parrot mode, just return them unchanged.
108
-
109
- Args:
110
- events: List of MIDI event dictionaries
111
-
112
- Returns:
113
- The same events unmodified
114
- """
115
  if not events:
116
  return []
117
-
118
- # Simply return the events as-is
119
- processed_events = []
120
- for event in events:
121
- processed_events.append(
122
- {
123
- "type": event.get("type"),
124
- "note": event.get("note"),
125
- "velocity": event.get("velocity"),
126
- "time": event.get("time"),
127
- "channel": event.get("channel", 0),
128
- }
129
- )
130
-
131
- return processed_events
132
 
133
 
134
  # =============================================================================
@@ -163,18 +96,8 @@ class EngineRegistry:
163
  """Get info about an engine"""
164
  if engine_id not in cls._engines:
165
  raise ValueError(f"Unknown engine: {engine_id}")
166
-
167
  engine = cls._engines[engine_id]()
168
  return {
169
  "id": engine_id,
170
  "name": engine.name,
171
- "description": getattr(engine, "description", "No description"),
172
  }
173
-
174
-
175
- # =============================================================================
176
- # GLOBAL ENGINE INSTANCES
177
- # =============================================================================
178
-
179
- # Create default engine instance
180
- default_engine = ParrotEngine()
 
1
  """
2
  Virtual MIDI Keyboard - Engines
3
 
4
+ MIDI processing engines that transform, analyze, or manipulate MIDI events.
 
5
  """
6
 
7
  from abc import ABC, abstractmethod
8
+ from typing import List, Dict, Any
 
9
 
10
 
11
  # =============================================================================
 
18
 
19
  def __init__(self, name: str):
20
  self.name = name
 
 
21
 
22
  @abstractmethod
23
  def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
32
  """
33
  pass
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  # =============================================================================
37
+ # PARROT ENGINE
38
  # =============================================================================
39
 
40
 
41
  class ParrotEngine(MIDIEngine):
42
  """
43
+ Parrot Engine - plays back MIDI exactly as recorded.
44
 
45
  This is the simplest engine - it just repeats what the user played.
 
46
  """
47
 
48
  def __init__(self):
49
  super().__init__("Parrot")
 
50
 
51
  def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
52
+ """Return events unchanged"""
 
 
 
 
 
 
 
 
53
  if not events:
54
  return []
55
+ return [
56
+ {
57
+ "type": e.get("type"),
58
+ "note": e.get("note"),
59
+ "velocity": e.get("velocity"),
60
+ "time": e.get("time"),
61
+ "channel": e.get("channel", 0),
62
+ }
63
+ for e in events
64
+ ]
 
 
 
 
 
65
 
66
 
67
  # =============================================================================
 
96
  """Get info about an engine"""
97
  if engine_id not in cls._engines:
98
  raise ValueError(f"Unknown engine: {engine_id}")
 
99
  engine = cls._engines[engine_id]()
100
  return {
101
  "id": engine_id,
102
  "name": engine.name,
 
103
  }
 
 
 
 
 
 
 
 
keyboard.html CHANGED
@@ -9,8 +9,7 @@
9
  <body>
10
  <!-- Welcome Header -->
11
  <div class="welcome-header">
12
- <h1 class="neon-text">🎹 VIRTUAL MIDI KEYBOARD</h1>
13
- <p class="subtitle">Play chords & melodies • Full polyphony (24 voices) • Record & export • Real-time MIDI monitoring</p>
14
  </div>
15
 
16
  <div id="mainContainer">
@@ -65,9 +64,6 @@
65
  <!-- External Dependencies -->
66
  <script src="https://unpkg.com/tone@next/build/Tone.js"></script>
67
 
68
- <!-- Engine -->
69
- <script src="/file=static/engine.js"></script>
70
-
71
  <!-- Application Logic -->
72
  <script src="/file=static/keyboard.js"></script>
73
  </body>
 
9
  <body>
10
  <!-- Welcome Header -->
11
  <div class="welcome-header">
12
+ <h1 class="neon-text">SYNTH<i>IA</i></h1>
 
13
  </div>
14
 
15
  <div id="mainContainer">
 
64
  <!-- External Dependencies -->
65
  <script src="https://unpkg.com/tone@next/build/Tone.js"></script>
66
 
 
 
 
67
  <!-- Application Logic -->
68
  <script src="/file=static/keyboard.js"></script>
69
  </body>
midi.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MIDI Utilities
3
+
4
+ Functions for working with MIDI events and files.
5
+ """
6
+
7
+ import io
8
+ import mido
9
+ from mido import MidiFile, MidiTrack, Message, MetaMessage
10
+ from config import MIDI_DEFAULTS
11
+
12
+
13
+ def events_to_midbytes(events, ticks_per_beat=None, tempo_bpm=None):
14
+ """
15
+ Convert browser MIDI events to a .mid file format.
16
+
17
+ Args:
18
+ events: List of dicts with {type, note, velocity, time, channel}
19
+ ticks_per_beat: MIDI ticks per beat resolution (default: from config)
20
+ tempo_bpm: Tempo in beats per minute (default: from config)
21
+
22
+ Returns:
23
+ bytes: Complete MIDI file as bytes
24
+ """
25
+ if ticks_per_beat is None:
26
+ ticks_per_beat = MIDI_DEFAULTS["ticks_per_beat"]
27
+ if tempo_bpm is None:
28
+ tempo_bpm = MIDI_DEFAULTS["tempo_bpm"]
29
+
30
+ mid = MidiFile(ticks_per_beat=ticks_per_beat)
31
+ track = MidiTrack()
32
+ mid.tracks.append(track)
33
+
34
+ # Set tempo meta message
35
+ tempo = mido.bpm2tempo(tempo_bpm)
36
+ track.append(MetaMessage("set_tempo", tempo=tempo, time=0))
37
+
38
+ # Sort events by time and convert to MIDI messages
39
+ evs = sorted(events, key=lambda e: e.get("time", 0.0))
40
+ last_time = 0.0
41
+ for ev in evs:
42
+ # Skip malformed events
43
+ if "time" not in ev or "type" not in ev or "note" not in ev:
44
+ continue
45
+
46
+ # Calculate delta time in ticks
47
+ dt_sec = max(0.0, ev["time"] - last_time)
48
+ last_time = ev["time"]
49
+ ticks = int(round(dt_sec * (ticks_per_beat * tempo_bpm) / 60.0))
50
+
51
+ # Create MIDI message
52
+ ev_type = ev["type"]
53
+ note = int(ev["note"])
54
+ vel = int(ev.get("velocity", 0))
55
+ channel = int(ev.get("channel", 0))
56
+
57
+ if ev_type == "note_on":
58
+ msg = Message(
59
+ "note_on", note=note, velocity=vel, time=ticks, channel=channel
60
+ )
61
+ else:
62
+ msg = Message(
63
+ "note_off", note=note, velocity=vel, time=ticks, channel=channel
64
+ )
65
+
66
+ track.append(msg)
67
+
68
+ # Write to bytes buffer
69
+ buf = io.BytesIO()
70
+ mid.save(file=buf)
71
+ buf.seek(0)
72
+ return buf.read()
73
+
74
+
75
+ def validate_event(event: dict) -> bool:
76
+ """
77
+ Validate that an event has required fields.
78
+
79
+ Args:
80
+ event: MIDI event dictionary
81
+
82
+ Returns:
83
+ bool: True if valid, False otherwise
84
+ """
85
+ required = {"type", "note", "time", "velocity"}
86
+ return all(field in event for field in required)
static/engine.js DELETED
@@ -1,242 +0,0 @@
1
- /**
2
- * Virtual MIDI Keyboard - Engine
3
- *
4
- * The engine processes MIDI signals and can:
5
- * - Capture and store MIDI events
6
- * - Play back captured sequences
7
- * - Process and transform MIDI data
8
- *
9
- * This is the core processing unit for all signal manipulation.
10
- */
11
-
12
- // =============================================================================
13
- // ENGINE STATE
14
- // =============================================================================
15
-
16
- class MIDIEngine {
17
- constructor() {
18
- this.recordings = {}; // Store multiple recordings
19
- this.currentRecordingId = null;
20
- this.isPlayingBack = false;
21
- this.playbackSpeed = 1.0; // 1.0 = normal speed
22
- this.callbacks = {
23
- onNoteOn: null,
24
- onNoteOff: null,
25
- onPlaybackStart: null,
26
- onPlaybackEnd: null,
27
- onProgress: null
28
- };
29
- }
30
-
31
- /**
32
- * Start recording a new performance
33
- * Returns the recording ID
34
- */
35
- startRecording(recordingName = 'Recording_' + Date.now()) {
36
- this.currentRecordingId = recordingName;
37
- this.recordings[recordingName] = {
38
- name: recordingName,
39
- events: [],
40
- startTime: performance.now(),
41
- duration: 0,
42
- metadata: {
43
- createdAt: new Date().toISOString(),
44
- instrument: 'default'
45
- }
46
- };
47
- return recordingName;
48
- }
49
-
50
- /**
51
- * Stop recording and return the recording
52
- */
53
- stopRecording() {
54
- if (!this.currentRecordingId) return null;
55
-
56
- const recording = this.recordings[this.currentRecordingId];
57
- if (recording && recording.events.length > 0) {
58
- const lastEvent = recording.events[recording.events.length - 1];
59
- recording.duration = lastEvent.time;
60
- }
61
-
62
- const recordingId = this.currentRecordingId;
63
- this.currentRecordingId = null;
64
- return recording;
65
- }
66
-
67
- /**
68
- * Add a MIDI event to the current recording
69
- */
70
- addEvent(event) {
71
- if (!this.currentRecordingId) return false;
72
-
73
- const recording = this.recordings[this.currentRecordingId];
74
- if (!recording) return false;
75
-
76
- recording.events.push({
77
- ...event,
78
- id: recording.events.length
79
- });
80
-
81
- return true;
82
- }
83
-
84
- /**
85
- * Get a recording by ID
86
- */
87
- getRecording(recordingId) {
88
- return this.recordings[recordingId];
89
- }
90
-
91
- /**
92
- * Get all recordings
93
- */
94
- getAllRecordings() {
95
- return Object.values(this.recordings);
96
- }
97
-
98
- /**
99
- * Delete a recording
100
- */
101
- deleteRecording(recordingId) {
102
- if (this.currentRecordingId === recordingId) {
103
- this.stopRecording();
104
- }
105
- delete this.recordings[recordingId];
106
- }
107
-
108
- /**
109
- * Play back a recording
110
- * Calls the provided callbacks for each note
111
- */
112
- async playback(recordingId, callbacks = {}) {
113
- const recording = this.getRecording(recordingId);
114
- if (!recording || recording.events.length === 0) {
115
- console.warn('No recording found or recording is empty');
116
- return;
117
- }
118
-
119
- // Merge provided callbacks with instance callbacks
120
- const finalCallbacks = { ...this.callbacks, ...callbacks };
121
-
122
- this.isPlayingBack = true;
123
- if (finalCallbacks.onPlaybackStart) {
124
- finalCallbacks.onPlaybackStart(recording);
125
- }
126
-
127
- const events = recording.events;
128
- let playbackStartTime = performance.now();
129
-
130
- for (let i = 0; i < events.length; i++) {
131
- if (!this.isPlayingBack) break; // Stop if playback was cancelled
132
-
133
- const event = events[i];
134
- const scheduledTime = (event.time / this.playbackSpeed) * 1000; // ms
135
- const now = performance.now() - playbackStartTime;
136
- const waitTime = scheduledTime - now;
137
-
138
- if (waitTime > 0) {
139
- await this.sleep(waitTime);
140
- }
141
-
142
- // Execute the event callback
143
- if (event.type === 'note_on' && finalCallbacks.onNoteOn) {
144
- finalCallbacks.onNoteOn(event);
145
- } else if (event.type === 'note_off' && finalCallbacks.onNoteOff) {
146
- finalCallbacks.onNoteOff(event);
147
- }
148
-
149
- // Progress callback
150
- if (finalCallbacks.onProgress) {
151
- finalCallbacks.onProgress({
152
- currentIndex: i,
153
- totalEvents: events.length,
154
- currentTime: event.time,
155
- totalDuration: recording.duration,
156
- progress: (i / events.length) * 100
157
- });
158
- }
159
- }
160
-
161
- this.isPlayingBack = false;
162
- if (finalCallbacks.onPlaybackEnd) {
163
- finalCallbacks.onPlaybackEnd(recording);
164
- }
165
- }
166
-
167
- /**
168
- * Stop playback
169
- */
170
- stopPlayback() {
171
- this.isPlayingBack = false;
172
- }
173
-
174
- /**
175
- * Set playback speed (1.0 = normal, 2.0 = double speed, etc.)
176
- */
177
- setPlaybackSpeed(speed) {
178
- this.playbackSpeed = Math.max(0.1, Math.min(2.0, speed));
179
- }
180
-
181
- /**
182
- * Export recording as JSON
183
- */
184
- exportAsJSON(recordingId) {
185
- const recording = this.getRecording(recordingId);
186
- if (!recording) return null;
187
- return JSON.stringify(recording, null, 2);
188
- }
189
-
190
- /**
191
- * Import recording from JSON
192
- */
193
- importFromJSON(jsonString, recordingName = null) {
194
- try {
195
- const recording = JSON.parse(jsonString);
196
- const id = recordingName || recording.name || 'imported_' + Date.now();
197
- this.recordings[id] = recording;
198
- return id;
199
- } catch (error) {
200
- console.error('Failed to import recording:', error);
201
- return null;
202
- }
203
- }
204
-
205
- /**
206
- * Get recording statistics
207
- */
208
- getStats(recordingId) {
209
- const recording = this.getRecording(recordingId);
210
- if (!recording) return null;
211
-
212
- const events = recording.events;
213
- const noteOnEvents = events.filter(e => e.type === 'note_on');
214
- const noteOffEvents = events.filter(e => e.type === 'note_off');
215
-
216
- return {
217
- recordingName: recording.name,
218
- totalEvents: events.length,
219
- noteOnCount: noteOnEvents.length,
220
- noteOffCount: noteOffEvents.length,
221
- duration: recording.duration,
222
- avgNotesPerSecond: (noteOnEvents.length / recording.duration).toFixed(2),
223
- minNote: Math.min(...noteOnEvents.map(e => e.note)),
224
- maxNote: Math.max(...noteOnEvents.map(e => e.note)),
225
- instrument: recording.metadata?.instrument || 'unknown'
226
- };
227
- }
228
-
229
- /**
230
- * Helper: Sleep for ms milliseconds
231
- */
232
- sleep(ms) {
233
- return new Promise(resolve => setTimeout(resolve, ms));
234
- }
235
- }
236
-
237
- // =============================================================================
238
- // EXPORT
239
- // =============================================================================
240
-
241
- // Create a global engine instance
242
- const midiEngine = new MIDIEngine();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/keyboard.js CHANGED
@@ -81,50 +81,91 @@ let startTime = 0;
81
  let events = [];
82
  const pressedKeys = new Set();
83
  let selectedEngine = 'parrot'; // Default engine
 
84
 
85
  // =============================================================================
86
- // INSTRUMENT CONFIGURATIONS
87
  // =============================================================================
88
 
89
- const instruments = {
90
- synth: () => new Tone.PolySynth(Tone.Synth, {
91
- maxPolyphony: 24,
92
- oscillator: { type: 'sine' },
93
- envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1 }
94
- }).toDestination(),
95
 
96
- piano: () => new Tone.PolySynth(Tone.Synth, {
97
- maxPolyphony: 24,
98
- oscillator: { type: 'triangle' },
99
- envelope: { attack: 0.001, decay: 0.2, sustain: 0.1, release: 2 }
100
- }).toDestination(),
101
-
102
- organ: () => new Tone.PolySynth(Tone.Synth, {
103
- maxPolyphony: 24,
104
- oscillator: { type: 'sine4' },
105
- envelope: { attack: 0.001, decay: 0, sustain: 1, release: 0.1 }
106
- }).toDestination(),
107
-
108
- bass: () => new Tone.PolySynth(Tone.Synth, {
109
- maxPolyphony: 24,
110
- oscillator: { type: 'sawtooth' },
111
- envelope: { attack: 0.01, decay: 0.1, sustain: 0.4, release: 1.5 },
112
- filter: { Q: 2, type: 'lowpass', rolloff: -12 }
113
- }).toDestination(),
114
-
115
- pluck: () => new Tone.PolySynth(Tone.Synth, {
116
- maxPolyphony: 24,
117
- oscillator: { type: 'triangle' },
118
- envelope: { attack: 0.001, decay: 0.3, sustain: 0, release: 0.3 }
119
- }).toDestination(),
120
 
121
- fm: () => new Tone.PolySynth(Tone.FMSynth, {
122
- maxPolyphony: 24,
123
- harmonicity: 3,
124
- modulationIndex: 10,
125
- envelope: { attack: 0.01, decay: 0.2, sustain: 0.2, release: 1 }
126
- }).toDestination()
127
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  function loadInstrument(type) {
130
  if (synth) {
@@ -139,6 +180,9 @@ function loadInstrument(type) {
139
  // =============================================================================
140
 
141
  function buildKeyboard() {
 
 
 
142
  for (let octave = 0; octave < numOctaves; octave++) {
143
  for (let i = 0; i < keys.length; i++) {
144
  const k = keys[i];
@@ -148,7 +192,9 @@ function buildKeyboard() {
148
  keyEl.className = 'key' + (k.black ? ' black' : '');
149
  keyEl.dataset.midi = midiNote;
150
 
151
- const shortcut = keyShortcuts[midiNote] || '';
 
 
152
  const shortcutHtml = shortcut ? `<div class="shortcut-hint">${shortcut}</div>` : '';
153
  keyEl.innerHTML = `<div style="padding-bottom:6px;font-size:11px">${shortcutHtml}${k.name}${octaveNum}</div>`;
154
 
@@ -212,9 +258,6 @@ function beginRecord() {
212
  playbackBtn.disabled = true;
213
  saveBtn.disabled = true;
214
 
215
- // Start engine recording
216
- const recordingId = midiEngine.startRecording('keyboard_' + Date.now());
217
-
218
  logToTerminal('', '');
219
  logToTerminal('▶▶▶ RECORDING STARTED ◀◀◀', 'timestamp');
220
  logToTerminal('', '');
@@ -228,9 +271,6 @@ function stopRecord() {
228
  saveBtn.disabled = events.length === 0;
229
  playbackBtn.disabled = events.length === 0;
230
 
231
- // Stop engine recording
232
- midiEngine.stopRecording();
233
-
234
  logToTerminal('', '');
235
  logToTerminal(`■■■ RECORDING STOPPED (${events.length} events captured) ■■■`, 'timestamp');
236
  logToTerminal('', '');
@@ -260,8 +300,6 @@ function noteOn(midiNote, velocity = 100) {
260
  channel: 0
261
  };
262
  events.push(event);
263
- // Also add to engine
264
- midiEngine.addEvent(event);
265
  }
266
  }
267
 
@@ -285,8 +323,6 @@ function noteOff(midiNote) {
285
  channel: 0
286
  };
287
  events.push(event);
288
- // Also add to engine
289
- midiEngine.addEvent(event);
290
  }
291
  }
292
 
@@ -301,6 +337,7 @@ function getKeyElement(midiNote) {
301
  document.addEventListener('keydown', async (ev) => {
302
  if (!keyboardToggle.checked) return;
303
  const key = ev.key.toLowerCase();
 
304
  if (!keyMap[key] || pressedKeys.has(key)) return;
305
 
306
  ev.preventDefault();
@@ -317,6 +354,7 @@ document.addEventListener('keydown', async (ev) => {
317
  document.addEventListener('keyup', (ev) => {
318
  if (!keyboardToggle.checked) return;
319
  const key = ev.key.toLowerCase();
 
320
  if (!keyMap[key] || !pressedKeys.has(key)) return;
321
 
322
  ev.preventDefault();
@@ -336,12 +374,16 @@ function attachPointerEvents() {
336
  keyboardEl.querySelectorAll('.key').forEach(k => {
337
  let pressed = false;
338
 
339
- k.addEventListener('pointerdown', (ev) => {
340
  ev.preventDefault();
341
  k.setPointerCapture(ev.pointerId);
342
  if (!pressed) {
343
  pressed = true;
344
  k.style.filter = 'brightness(0.85)';
 
 
 
 
345
  const midi = parseInt(k.dataset.midi);
346
  const vel = ev.pressure ? Math.round(ev.pressure * 127) : 100;
347
  noteOn(midi, vel);
@@ -568,13 +610,19 @@ playbackBtn.addEventListener('click', async () => {
568
 
569
  saveBtn.addEventListener('click', () => saveMIDI());
570
 
 
571
  // =============================================================================
572
  // INITIALIZATION
573
  // =============================================================================
574
 
575
- function init() {
 
 
 
 
576
  loadInstrument('synth');
577
- buildKeyboard();
 
578
  attachPointerEvents();
579
  initTerminal();
580
 
@@ -583,7 +631,9 @@ function init() {
583
  stopBtn.disabled = true;
584
  saveBtn.disabled = true;
585
  playbackBtn.disabled = true;
 
 
586
  }
587
 
588
- // Start the application
589
- init();
 
81
  let events = [];
82
  const pressedKeys = new Set();
83
  let selectedEngine = 'parrot'; // Default engine
84
+ let serverConfig = null; // Will hold instruments and keyboard config from server
85
 
86
  // =============================================================================
87
+ // INSTRUMENT FACTORY
88
  // =============================================================================
89
 
90
+ function buildInstruments(instrumentConfigs) {
91
+ /**
92
+ * Build Tone.js synth instances from config
93
+ * instrumentConfigs: Object from server with instrument definitions
94
+ */
95
+ const instruments = {};
96
 
97
+ for (const [key, config] of Object.entries(instrumentConfigs)) {
98
+ const baseOptions = {
99
+ maxPolyphony: 24,
100
+ oscillator: config.oscillator ? { type: config.oscillator } : undefined,
101
+ envelope: config.envelope,
102
+ };
103
+
104
+ // Remove undefined keys
105
+ Object.keys(baseOptions).forEach(k => baseOptions[k] === undefined && delete baseOptions[k]);
106
+
107
+ if (config.type === 'FMSynth') {
108
+ baseOptions.harmonicity = config.harmonicity;
109
+ baseOptions.modulationIndex = config.modulationIndex;
110
+ instruments[key] = () => new Tone.PolySynth(Tone.FMSynth, baseOptions).toDestination();
111
+ } else {
112
+ instruments[key] = () => new Tone.PolySynth(Tone.Synth, baseOptions).toDestination();
113
+ }
114
+ }
 
 
 
 
 
 
115
 
116
+ return instruments;
117
+ }
118
+
119
+ let instruments = {}; // Will be populated after config is fetched
120
+
121
+ // =============================================================================
122
+ // INITIALIZATION FROM SERVER CONFIG
123
+ // =============================================================================
124
+
125
+ async function initializeFromConfig() {
126
+ /**
127
+ * Fetch configuration from Python server and initialize UI
128
+ */
129
+ try {
130
+ const response = await fetch('/gradio_api/api/config');
131
+ if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
132
+
133
+ serverConfig = await response.json();
134
+
135
+ // Build instruments from config
136
+ instruments = buildInstruments(serverConfig.instruments);
137
+
138
+ // Build keyboard shortcut maps from server config
139
+ window.keyboardShortcutsFromServer = serverConfig.keyboard_shortcuts;
140
+ window.keyMapFromServer = {};
141
+ for (const [midiStr, key] of Object.entries(serverConfig.keyboard_shortcuts)) {
142
+ window.keyMapFromServer[key.toLowerCase()] = parseInt(midiStr);
143
+ }
144
+
145
+ console.log('✓ Configuration loaded from server');
146
+ console.log(`✓ ${Object.keys(instruments).length} instruments ready`);
147
+ console.log(`✓ ${Object.keys(window.keyboardShortcutsFromServer).length} keyboard shortcuts configured`);
148
+
149
+ // Render keyboard after config is loaded
150
+ buildKeyboard();
151
+
152
+ } catch (error) {
153
+ console.error('Failed to load configuration:', error);
154
+ // Fallback: Use hardcoded values for development/debugging
155
+ console.warn('Using fallback hardcoded configuration');
156
+ instruments = buildInstruments({
157
+ 'synth': {name: 'Synth', type: 'Synth', oscillator: 'sine', envelope: {attack: 0.005, decay: 0.1, sustain: 0.3, release: 1}},
158
+ 'piano': {name: 'Piano', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.2, sustain: 0.1, release: 2}},
159
+ 'organ': {name: 'Organ', type: 'Synth', oscillator: 'sine4', envelope: {attack: 0.001, decay: 0, sustain: 1, release: 0.1}},
160
+ 'bass': {name: 'Bass', type: 'Synth', oscillator: 'sawtooth', envelope: {attack: 0.01, decay: 0.1, sustain: 0.4, release: 1.5}},
161
+ 'pluck': {name: 'Pluck', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.3, sustain: 0, release: 0.3}},
162
+ 'fm': {name: 'FM', type: 'FMSynth', harmonicity: 3, modulationIndex: 10, envelope: {attack: 0.01, decay: 0.2, sustain: 0.2, release: 1}}
163
+ });
164
+ window.keyboardShortcutsFromServer = keyShortcuts; // Use hardcoded as fallback
165
+ window.keyMapFromServer = keyMap; // Use hardcoded as fallback
166
+ buildKeyboard();
167
+ }
168
+ }
169
 
170
  function loadInstrument(type) {
171
  if (synth) {
 
180
  // =============================================================================
181
 
182
  function buildKeyboard() {
183
+ // Clear any existing keys
184
+ keyboardEl.innerHTML = '';
185
+
186
  for (let octave = 0; octave < numOctaves; octave++) {
187
  for (let i = 0; i < keys.length; i++) {
188
  const k = keys[i];
 
192
  keyEl.className = 'key' + (k.black ? ' black' : '');
193
  keyEl.dataset.midi = midiNote;
194
 
195
+ // Use server config shortcuts if available, otherwise fallback to hardcoded
196
+ const shortcutsMap = window.keyboardShortcutsFromServer || keyShortcuts;
197
+ const shortcut = shortcutsMap[midiNote] || '';
198
  const shortcutHtml = shortcut ? `<div class="shortcut-hint">${shortcut}</div>` : '';
199
  keyEl.innerHTML = `<div style="padding-bottom:6px;font-size:11px">${shortcutHtml}${k.name}${octaveNum}</div>`;
200
 
 
258
  playbackBtn.disabled = true;
259
  saveBtn.disabled = true;
260
 
 
 
 
261
  logToTerminal('', '');
262
  logToTerminal('▶▶▶ RECORDING STARTED ◀◀◀', 'timestamp');
263
  logToTerminal('', '');
 
271
  saveBtn.disabled = events.length === 0;
272
  playbackBtn.disabled = events.length === 0;
273
 
 
 
 
274
  logToTerminal('', '');
275
  logToTerminal(`■■■ RECORDING STOPPED (${events.length} events captured) ■■■`, 'timestamp');
276
  logToTerminal('', '');
 
300
  channel: 0
301
  };
302
  events.push(event);
 
 
303
  }
304
  }
305
 
 
323
  channel: 0
324
  };
325
  events.push(event);
 
 
326
  }
327
  }
328
 
 
337
  document.addEventListener('keydown', async (ev) => {
338
  if (!keyboardToggle.checked) return;
339
  const key = ev.key.toLowerCase();
340
+ const keyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
341
  if (!keyMap[key] || pressedKeys.has(key)) return;
342
 
343
  ev.preventDefault();
 
354
  document.addEventListener('keyup', (ev) => {
355
  if (!keyboardToggle.checked) return;
356
  const key = ev.key.toLowerCase();
357
+ const keyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
358
  if (!keyMap[key] || !pressedKeys.has(key)) return;
359
 
360
  ev.preventDefault();
 
374
  keyboardEl.querySelectorAll('.key').forEach(k => {
375
  let pressed = false;
376
 
377
+ k.addEventListener('pointerdown', async (ev) => {
378
  ev.preventDefault();
379
  k.setPointerCapture(ev.pointerId);
380
  if (!pressed) {
381
  pressed = true;
382
  k.style.filter = 'brightness(0.85)';
383
+
384
+ // Ensure Tone.js audio context is started
385
+ await Tone.start();
386
+
387
  const midi = parseInt(k.dataset.midi);
388
  const vel = ev.pressure ? Math.round(ev.pressure * 127) : 100;
389
  noteOn(midi, vel);
 
610
 
611
  saveBtn.addEventListener('click', () => saveMIDI());
612
 
613
+ // =============================================================================
614
  // =============================================================================
615
  // INITIALIZATION
616
  // =============================================================================
617
 
618
+ async function init() {
619
+ // First, load configuration from server
620
+ await initializeFromConfig();
621
+
622
+ // Then load default instrument (synth)
623
  loadInstrument('synth');
624
+
625
+ // Setup keyboard event listeners and UI
626
  attachPointerEvents();
627
  initTerminal();
628
 
 
631
  stopBtn.disabled = true;
632
  saveBtn.disabled = true;
633
  playbackBtn.disabled = true;
634
+
635
+ console.log('✓ Virtual MIDI Keyboard initialized');
636
  }
637
 
638
+ // Start the application when DOM is ready
639
+ document.addEventListener('DOMContentLoaded', init);
static/styles.css CHANGED
@@ -32,14 +32,6 @@ body {
32
  letter-spacing: 2px;
33
  }
34
 
35
- .subtitle {
36
- font-size: 1em;
37
- color: #00ffff;
38
- margin: 0;
39
- text-shadow: 0 0 5px #00ffff;
40
- opacity: 0.9;
41
- }
42
-
43
  #mainContainer {
44
  display: flex;
45
  flex-direction: column;
 
32
  letter-spacing: 2px;
33
  }
34
 
 
 
 
 
 
 
 
 
35
  #mainContainer {
36
  display: flex;
37
  flex-direction: column;