Spaces:
Running
Running
Full refactoring and design of the repo
Browse files- app.py +50 -184
- config.py +142 -0
- engines.py +15 -92
- keyboard.html +1 -5
- midi.py +86 -0
- static/engine.js +0 -242
- static/keyboard.js +104 -54
- static/styles.css +0 -8
app.py
CHANGED
|
@@ -1,111 +1,30 @@
|
|
| 1 |
"""
|
| 2 |
Virtual MIDI Keyboard - Gradio Application
|
| 3 |
|
| 4 |
-
|
| 5 |
-
- Play notes with various synthesizer sounds
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 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
|
| 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 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 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
|
| 207 |
# =============================================================================
|
| 208 |
|
| 209 |
-
# Load
|
| 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
|
| 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
|
| 234 |
with gr.Blocks(title="Virtual MIDI Keyboard") as demo:
|
| 235 |
gr.HTML(iframe_html)
|
| 236 |
|
| 237 |
-
# Hidden
|
| 238 |
-
with gr.Group(visible=False)
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
|
|
|
|
|
|
|
|
|
|
| 258 |
engine_btn = gr.Button("process_engine", visible=False)
|
| 259 |
engine_btn.click(
|
| 260 |
-
fn=
|
|
|
|
|
|
|
| 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 |
-
|
| 5 |
-
analyze, or manipulate MIDI events in various ways.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from abc import ABC, abstractmethod
|
| 9 |
-
from typing import List, Dict, Any
|
| 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
|
| 90 |
# =============================================================================
|
| 91 |
|
| 92 |
|
| 93 |
class ParrotEngine(MIDIEngine):
|
| 94 |
"""
|
| 95 |
-
Parrot Engine -
|
| 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 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 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">
|
| 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
|
| 87 |
// =============================================================================
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
}
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
}
|
| 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 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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;
|