FJFehr commited on
Commit
9ccc1e0
Β·
1 Parent(s): 055747e

Included the reverse parrot, more keyboard keys and panic button

Browse files
Files changed (5) hide show
  1. README.md +31 -67
  2. config.py +11 -4
  3. engines.py +62 -1
  4. keyboard.html +2 -0
  5. static/keyboard.js +98 -19
README.md CHANGED
@@ -1,98 +1,62 @@
1
  ---
2
- title: Virtual Keyboard
3
  emoji: 🎹
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
10
- short_description: A small virtual midi keyboard
11
  ---
12
 
13
- # Virtual MIDI Keyboard
14
 
15
- Minimal browser MIDI keyboard: play in the browser, record note events, export a .mid file.
16
 
17
- ## Features
18
 
19
- - 🎹 Two-octave virtual piano keyboard
20
- - 🎡 Multiple instrument sounds (Synth, Piano, Organ, Bass, Pluck, FM)
21
- - ⌨️ Computer keyboard input support
22
- - πŸ“Ή MIDI event recording with timestamps
23
- - πŸ’Ύ Export recordings as .mid files
24
- - πŸ“Š Real-time MIDI event monitor
25
- - 🎨 Clean, responsive interface
26
-
27
- ## Project Structure
28
 
29
  ```
30
- virtual_keyboard/
31
- β”œβ”€β”€ app.py # Gradio server + MIDI conversion
32
- β”œβ”€β”€ keyboard.html # Main UI structure
 
 
 
33
  β”œβ”€β”€ static/
34
- β”‚ β”œβ”€β”€ styles.css # All application styles
35
- β”‚ β”œβ”€β”€ keyboard.js # Client-side logic
36
- β”‚ └── README.md # Static assets documentation
37
- β”œβ”€β”€ requirements.txt # Python dependencies
38
- β”œβ”€β”€ pyproject.toml # Project metadata
39
- └── README.md # This file
40
  ```
41
 
42
- ## Files
43
-
44
- - app.py: Gradio app + MIDI export API
45
- - keyboard.html: client-side keyboard (Tone.js)
46
-
47
- ## Run locally
48
 
49
  ```bash
50
- uv venv
51
  uv pip install -r requirements.txt
 
 
52
  uv run python app.py
53
  ```
54
 
55
- Open http://127.0.0.1:7860
56
-
57
- ## Deploy to Hugging Face Spaces
58
-
59
- ### Quick Setup
60
-
61
- 1. **Create a Space**
62
- - Go to https://huggingface.co/spaces
63
- - Click "Create new Space"
64
- - Choose **Gradio SDK**
65
- - Name it (e.g., `virtual_keyboard`)
66
 
67
- 2. **Add HF remote and push**
68
- ```bash
69
- git remote add hf git@hf.co:spaces/YOUR_USERNAME/virtual_keyboard
70
- git push hf main
71
- ```
72
-
73
- That's it! Your Space will automatically deploy.
74
-
75
- ### Push to Both GitHub and HF
76
 
77
  ```bash
78
- git push origin main && git push hf main
 
79
  ```
80
 
81
- ## API
82
 
83
- The browser posts events to the Gradio call endpoint:
 
 
84
 
85
- ```
86
- POST /gradio_api/call/save_midi
87
- {
88
- "data": [events]
89
- }
90
- ```
91
 
92
- The response returns an event_id. Fetch the result from:
93
-
94
- ```
95
- GET /gradio_api/call/save_midi/{event_id}
96
- ```
97
 
98
- The response includes base64 MIDI data at data[0].midi_base64.
 
1
  ---
2
+ title: SYNTHIA
3
  emoji: 🎹
4
+ colorFrom: purple
5
+ colorTo: cyan
6
  sdk: gradio
7
  sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Browser-based MIDI keyboard with recording and synthesis
11
  ---
12
 
13
+ # SYNTHIA
14
 
15
+ A minimal, responsive browser-based MIDI keyboard. Play live, record performances, and export as MIDI files. 🎹
16
 
 
17
 
18
+ ## πŸ—‚οΈ Project Structure
 
 
 
 
 
 
 
 
19
 
20
  ```
21
+ .
22
+ β”œβ”€β”€ app.py # Gradio server & API endpoints
23
+ β”œβ”€β”€ config.py # Centralized configuration
24
+ β”œβ”€β”€ engines.py # MIDI processing engines
25
+ β”œβ”€β”€ midi.py # MIDI file utilities
26
+ β”œβ”€β”€ keyboard.html # HTML structure
27
  β”œβ”€β”€ static/
28
+ β”‚ β”œβ”€β”€ keyboard.js # Client-side audio (Tone.js)
29
+ β”‚ └── styles.css # Styling & animations
30
+ β”œβ”€β”€ requirements.txt # Python dependencies
31
+ └── README.md # This file
 
 
32
  ```
33
 
34
+ ## πŸš€ Quick Start
 
 
 
 
 
35
 
36
  ```bash
37
+ # Install dependencies
38
  uv pip install -r requirements.txt
39
+
40
+ # Run the app
41
  uv run python app.py
42
  ```
43
 
44
+ Open **http://127.0.0.1:7861**
 
 
 
 
 
 
 
 
 
 
45
 
46
+ ## 🌐 Deploy to Hugging Face Spaces
 
 
 
 
 
 
 
 
47
 
48
  ```bash
49
+ git remote add hf git@hf.co:spaces/YOUR_USERNAME/synthia
50
+ git push hf main
51
  ```
52
 
53
+ ## πŸ”§ Technology
54
 
55
+ - **Frontend**: Tone.js v6+ (Web Audio API)
56
+ - **Backend**: Gradio 6.x + Python 3.10+
57
+ - **MIDI**: mido library
58
 
59
+ ## πŸ“ License
 
 
 
 
 
60
 
61
+ Open source - free to use and modify.
 
 
 
 
62
 
 
config.py CHANGED
@@ -43,6 +43,7 @@ KEYBOARD_KEYS = [
43
 
44
  # Computer keyboard shortcuts to MIDI notes
45
  KEYBOARD_SHORTCUTS = {
 
46
  60: "A", # C4
47
  61: "W", # C#4
48
  62: "S", # D4
@@ -55,6 +56,12 @@ KEYBOARD_SHORTCUTS = {
55
  69: "H", # A4
56
  70: "U", # A#4
57
  71: "J", # B4
 
 
 
 
 
 
58
  }
59
 
60
  # =============================================================================
@@ -80,7 +87,7 @@ INSTRUMENTS = {
80
  "attack": 0.005,
81
  "decay": 0.1,
82
  "sustain": 0.3,
83
- "release": 1,
84
  },
85
  },
86
  "piano": {
@@ -91,7 +98,7 @@ INSTRUMENTS = {
91
  "attack": 0.001,
92
  "decay": 0.2,
93
  "sustain": 0.1,
94
- "release": 2,
95
  },
96
  },
97
  "organ": {
@@ -113,7 +120,7 @@ INSTRUMENTS = {
113
  "attack": 0.01,
114
  "decay": 0.1,
115
  "sustain": 0.4,
116
- "release": 1.5,
117
  },
118
  },
119
  "pluck": {
@@ -136,7 +143,7 @@ INSTRUMENTS = {
136
  "attack": 0.01,
137
  "decay": 0.2,
138
  "sustain": 0.2,
139
- "release": 1,
140
  },
141
  },
142
  }
 
43
 
44
  # Computer keyboard shortcuts to MIDI notes
45
  KEYBOARD_SHORTCUTS = {
46
+ # First octave (C4-B4)
47
  60: "A", # C4
48
  61: "W", # C#4
49
  62: "S", # D4
 
56
  69: "H", # A4
57
  70: "U", # A#4
58
  71: "J", # B4
59
+ # Second octave (C5-E5)
60
+ 72: "K", # C5
61
+ 73: "O", # C#5
62
+ 74: "L", # D5
63
+ 75: "P", # D#5
64
+ 76: ";", # E5
65
  }
66
 
67
  # =============================================================================
 
87
  "attack": 0.005,
88
  "decay": 0.1,
89
  "sustain": 0.3,
90
+ "release": 0.2,
91
  },
92
  },
93
  "piano": {
 
98
  "attack": 0.001,
99
  "decay": 0.2,
100
  "sustain": 0.1,
101
+ "release": 0.3,
102
  },
103
  },
104
  "organ": {
 
120
  "attack": 0.01,
121
  "decay": 0.1,
122
  "sustain": 0.4,
123
+ "release": 0.3,
124
  },
125
  },
126
  "pluck": {
 
143
  "attack": 0.01,
144
  "decay": 0.2,
145
  "sustain": 0.2,
146
+ "release": 0.2,
147
  },
148
  },
149
  }
engines.py CHANGED
@@ -64,6 +64,67 @@ class ParrotEngine(MIDIEngine):
64
  ]
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # =============================================================================
68
  # ENGINE REGISTRY
69
  # =============================================================================
@@ -72,7 +133,7 @@ class ParrotEngine(MIDIEngine):
72
  class EngineRegistry:
73
  """Registry for managing available MIDI engines"""
74
 
75
- _engines = {"parrot": ParrotEngine}
76
 
77
  @classmethod
78
  def register(cls, engine_id: str, engine_class: type):
 
64
  ]
65
 
66
 
67
+ # =============================================================================
68
+ # REVERSE PARROT ENGINE
69
+ # =============================================================================
70
+
71
+
72
+ class ReverseParrotEngine(MIDIEngine):
73
+ """
74
+ Reverse Parrot Engine - plays back MIDI in reverse order.
75
+
76
+ Takes the recorded performance and reverses the sequence of notes,
77
+ playing them backwards while maintaining their timing relationships.
78
+ """
79
+
80
+ def __init__(self):
81
+ super().__init__("Reverse Parrot")
82
+
83
+ def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
84
+ """Reverse the sequence of note numbers while keeping timing and event types"""
85
+ if not events:
86
+ return []
87
+
88
+ # Separate note_on and note_off events
89
+ note_on_events = [e for e in events if e.get("type") == "note_on"]
90
+ note_off_events = [e for e in events if e.get("type") == "note_off"]
91
+
92
+ # Extract note numbers from note_on events and reverse them
93
+ on_notes = [e.get("note") for e in note_on_events]
94
+ reversed_on_notes = list(reversed(on_notes))
95
+
96
+ # Extract note numbers from note_off events and reverse them
97
+ off_notes = [e.get("note") for e in note_off_events]
98
+ reversed_off_notes = list(reversed(off_notes))
99
+
100
+ # Reconstruct events with reversed notes but original structure
101
+ result = []
102
+ on_index = 0
103
+ off_index = 0
104
+
105
+ for event in events:
106
+ if event.get("type") == "note_on":
107
+ result.append({
108
+ "type": "note_on",
109
+ "note": reversed_on_notes[on_index],
110
+ "velocity": event.get("velocity"),
111
+ "time": event.get("time"),
112
+ "channel": event.get("channel", 0),
113
+ })
114
+ on_index += 1
115
+ elif event.get("type") == "note_off":
116
+ result.append({
117
+ "type": "note_off",
118
+ "note": reversed_off_notes[off_index],
119
+ "velocity": event.get("velocity"),
120
+ "time": event.get("time"),
121
+ "channel": event.get("channel", 0),
122
+ })
123
+ off_index += 1
124
+
125
+ return result
126
+
127
+
128
  # =============================================================================
129
  # ENGINE REGISTRY
130
  # =============================================================================
 
133
  class EngineRegistry:
134
  """Registry for managing available MIDI engines"""
135
 
136
+ _engines = {"parrot": ParrotEngine, "reverse_parrot": ReverseParrotEngine}
137
 
138
  @classmethod
139
  def register(cls, engine_id: str, engine_class: type):
keyboard.html CHANGED
@@ -34,6 +34,7 @@
34
  Engine:
35
  <select id="engineSelect">
36
  <option value="parrot">Parrot</option>
 
37
  </select>
38
  </label>
39
 
@@ -41,6 +42,7 @@
41
  <button id="stopBtn" disabled>Stop</button>
42
  <button id="playbackBtn" disabled>Playback</button>
43
  <button id="saveBtn" disabled>Save MIDI</button>
 
44
 
45
  <label>
46
  <input type="checkbox" id="keyboardToggle">
 
34
  Engine:
35
  <select id="engineSelect">
36
  <option value="parrot">Parrot</option>
37
+ <option value="reverse_parrot">Reverse Parrot</option>
38
  </select>
39
  </label>
40
 
 
42
  <button id="stopBtn" disabled>Stop</button>
43
  <button id="playbackBtn" disabled>Playback</button>
44
  <button id="saveBtn" disabled>Save MIDI</button>
45
+ <button id="panicBtn" style="background: #ff4444; color: white;">Panic 🚨</button>
46
 
47
  <label>
48
  <input type="checkbox" id="keyboardToggle">
static/keyboard.js CHANGED
@@ -33,7 +33,7 @@ const keys = [
33
  {name:'B', offset:11, black:false}
34
  ];
35
 
36
- // Computer keyboard mapping (C4 octave)
37
  const keyMap = {
38
  'a': 60, // C4
39
  'w': 61, // C#4
@@ -46,13 +46,19 @@ const keyMap = {
46
  'y': 68, // G#4
47
  'h': 69, // A4
48
  'u': 70, // A#4
49
- 'j': 71 // B4
 
 
 
 
 
50
  };
51
 
52
- // Keyboard shortcuts displayed on keys
53
  const keyShortcuts = {
54
  60: 'A', 61: 'W', 62: 'S', 63: 'E', 64: 'D', 65: 'F',
55
- 66: 'T', 67: 'G', 68: 'Y', 69: 'H', 70: 'U', 71: 'J'
 
56
  };
57
 
58
  // =============================================================================
@@ -65,6 +71,7 @@ const recordBtn = document.getElementById('recordBtn');
65
  const stopBtn = document.getElementById('stopBtn');
66
  const playbackBtn = document.getElementById('playbackBtn');
67
  const saveBtn = document.getElementById('saveBtn');
 
68
  const keyboardToggle = document.getElementById('keyboardToggle');
69
  const instrumentSelect = document.getElementById('instrumentSelect');
70
  const engineSelect = document.getElementById('engineSelect');
@@ -142,10 +149,6 @@ async function initializeFromConfig() {
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
 
@@ -154,12 +157,12 @@ async function initializeFromConfig() {
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
@@ -529,6 +532,14 @@ engineSelect.addEventListener('change', (e) => {
529
  playbackBtn.addEventListener('click', async () => {
530
  if (events.length === 0) return alert('No recording to play back');
531
 
 
 
 
 
 
 
 
 
532
  statusEl.textContent = 'Playing back...';
533
  playbackBtn.disabled = true;
534
  recordBtn.disabled = true;
@@ -538,11 +549,47 @@ playbackBtn.addEventListener('click', async () => {
538
  logToTerminal('', '');
539
 
540
  try {
541
- // For now, skip engine processing and directly play recorded events
542
- // TODO: Call engine API when Gradio routing is figured out
543
- const processedEvents = events;
544
 
545
- console.log(`Playing back ${processedEvents.length} events`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
  // Play back the recorded events
548
  statusEl.textContent = 'Playing back...';
@@ -550,7 +597,16 @@ playbackBtn.addEventListener('click', async () => {
550
 
551
  const playEvent = () => {
552
  if (eventIndex >= processedEvents.length) {
553
- // Playback complete
 
 
 
 
 
 
 
 
 
554
  statusEl.textContent = 'Playback complete';
555
  playbackBtn.disabled = false;
556
  recordBtn.disabled = false;
@@ -605,11 +661,36 @@ playbackBtn.addEventListener('click', async () => {
605
  statusEl.textContent = 'Playback error: ' + err.message;
606
  playbackBtn.disabled = false;
607
  recordBtn.disabled = false;
 
 
 
 
 
 
 
 
608
  }
609
  });
610
 
611
  saveBtn.addEventListener('click', () => saveMIDI());
612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  // =============================================================================
614
  // =============================================================================
615
  // INITIALIZATION
@@ -631,8 +712,6 @@ async function init() {
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
 
33
  {name:'B', offset:11, black:false}
34
  ];
35
 
36
+ // Computer keyboard mapping (fallback)
37
  const keyMap = {
38
  'a': 60, // C4
39
  'w': 61, // C#4
 
46
  'y': 68, // G#4
47
  'h': 69, // A4
48
  'u': 70, // A#4
49
+ 'j': 71, // B4
50
+ 'k': 72, // C5
51
+ 'o': 73, // C#5
52
+ 'l': 74, // D5
53
+ 'p': 75, // D#5
54
+ ';': 76 // E5
55
  };
56
 
57
+ // Keyboard shortcuts displayed on keys (fallback)
58
  const keyShortcuts = {
59
  60: 'A', 61: 'W', 62: 'S', 63: 'E', 64: 'D', 65: 'F',
60
+ 66: 'T', 67: 'G', 68: 'Y', 69: 'H', 70: 'U', 71: 'J',
61
+ 72: 'K', 73: 'O', 74: 'L', 75: 'P', 76: ';'
62
  };
63
 
64
  // =============================================================================
 
71
  const stopBtn = document.getElementById('stopBtn');
72
  const playbackBtn = document.getElementById('playbackBtn');
73
  const saveBtn = document.getElementById('saveBtn');
74
+ const panicBtn = document.getElementById('panicBtn');
75
  const keyboardToggle = document.getElementById('keyboardToggle');
76
  const instrumentSelect = document.getElementById('instrumentSelect');
77
  const engineSelect = document.getElementById('engineSelect');
 
149
  window.keyMapFromServer[key.toLowerCase()] = parseInt(midiStr);
150
  }
151
 
 
 
 
 
152
  // Render keyboard after config is loaded
153
  buildKeyboard();
154
 
 
157
  // Fallback: Use hardcoded values for development/debugging
158
  console.warn('Using fallback hardcoded configuration');
159
  instruments = buildInstruments({
160
+ 'synth': {name: 'Synth', type: 'Synth', oscillator: 'sine', envelope: {attack: 0.005, decay: 0.1, sustain: 0.3, release: 0.2}},
161
+ 'piano': {name: 'Piano', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.2, sustain: 0.1, release: 0.3}},
162
  'organ': {name: 'Organ', type: 'Synth', oscillator: 'sine4', envelope: {attack: 0.001, decay: 0, sustain: 1, release: 0.1}},
163
+ 'bass': {name: 'Bass', type: 'Synth', oscillator: 'sawtooth', envelope: {attack: 0.01, decay: 0.1, sustain: 0.4, release: 0.3}},
164
  'pluck': {name: 'Pluck', type: 'Synth', oscillator: 'triangle', envelope: {attack: 0.001, decay: 0.3, sustain: 0, release: 0.3}},
165
+ 'fm': {name: 'FM', type: 'FMSynth', harmonicity: 3, modulationIndex: 10, envelope: {attack: 0.01, decay: 0.2, sustain: 0.2, release: 0.2}}
166
  });
167
  window.keyboardShortcutsFromServer = keyShortcuts; // Use hardcoded as fallback
168
  window.keyMapFromServer = keyMap; // Use hardcoded as fallback
 
532
  playbackBtn.addEventListener('click', async () => {
533
  if (events.length === 0) return alert('No recording to play back');
534
 
535
+ // Ensure all notes are off before starting playback
536
+ if (synth) {
537
+ synth.releaseAll();
538
+ }
539
+ keyboardEl.querySelectorAll('.key').forEach(k => {
540
+ k.style.filter = '';
541
+ });
542
+
543
  statusEl.textContent = 'Playing back...';
544
  playbackBtn.disabled = true;
545
  recordBtn.disabled = true;
 
549
  logToTerminal('', '');
550
 
551
  try {
552
+ // Process events through the selected engine
553
+ let processedEvents = events;
554
+ const selectedEngine = engineSelect.value;
555
 
556
+ if (selectedEngine && selectedEngine !== 'parrot') {
557
+ // Step 1: Start the engine processing call
558
+ const startResp = await fetch('/gradio_api/call/process_engine', {
559
+ method: 'POST',
560
+ headers: { 'Content-Type': 'application/json' },
561
+ body: JSON.stringify({
562
+ data: [{
563
+ engine_id: selectedEngine,
564
+ events: events
565
+ }]
566
+ })
567
+ });
568
+
569
+ if (!startResp.ok) {
570
+ console.error('Engine API start failed:', startResp.status);
571
+ } else {
572
+ const startJson = await startResp.json();
573
+
574
+
575
+ // Step 2: Poll for the result
576
+ if (startJson && startJson.event_id) {
577
+ const resultResp = await fetch(`/gradio_api/call/process_engine/${startJson.event_id}`);
578
+ if (resultResp.ok) {
579
+ const resultText = await resultResp.text();
580
+ const dataLine = resultText.split('\n').find(line => line.startsWith('data:'));
581
+ if (dataLine) {
582
+ const payloadList = JSON.parse(dataLine.replace('data:', '').trim());
583
+ const result = Array.isArray(payloadList) ? payloadList[0] : null;
584
+
585
+ if (result && result.events) {
586
+ processedEvents = result.events;
587
+ }
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
 
594
  // Play back the recorded events
595
  statusEl.textContent = 'Playing back...';
 
597
 
598
  const playEvent = () => {
599
  if (eventIndex >= processedEvents.length) {
600
+ // Playback complete - ensure all notes are off
601
+ if (synth) {
602
+ synth.releaseAll();
603
+ }
604
+
605
+ // Clear all key highlights
606
+ keyboardEl.querySelectorAll('.key').forEach(k => {
607
+ k.style.filter = '';
608
+ });
609
+
610
  statusEl.textContent = 'Playback complete';
611
  playbackBtn.disabled = false;
612
  recordBtn.disabled = false;
 
661
  statusEl.textContent = 'Playback error: ' + err.message;
662
  playbackBtn.disabled = false;
663
  recordBtn.disabled = false;
664
+
665
+ // Ensure all notes are off on error
666
+ if (synth) {
667
+ synth.releaseAll();
668
+ }
669
+ keyboardEl.querySelectorAll('.key').forEach(k => {
670
+ k.style.filter = '';
671
+ });
672
  }
673
  });
674
 
675
  saveBtn.addEventListener('click', () => saveMIDI());
676
 
677
+ panicBtn.addEventListener('click', () => {
678
+ // Stop all notes immediately
679
+ if (synth) {
680
+ synth.releaseAll();
681
+ }
682
+
683
+ // Clear all pressed keys
684
+ pressedKeys.clear();
685
+
686
+ // Reset all visual key highlights
687
+ keyboardEl.querySelectorAll('.key').forEach(k => {
688
+ k.style.filter = '';
689
+ });
690
+
691
+ logToTerminal('🚨 PANIC - All notes stopped', 'timestamp');
692
+ });
693
+
694
  // =============================================================================
695
  // =============================================================================
696
  // INITIALIZATION
 
712
  stopBtn.disabled = true;
713
  saveBtn.disabled = true;
714
  playbackBtn.disabled = true;
 
 
715
  }
716
 
717
  // Start the application when DOM is ready