FJFehr commited on
Commit
cee0097
·
1 Parent(s): f7e26a4

refactor: consolidate getter functions, event listeners, and polish UI

Browse files
Files changed (9) hide show
  1. README.md +197 -36
  2. app.py +176 -41
  3. engines.py +53 -8
  4. keyboard.html +178 -49
  5. midi_model.py +613 -64
  6. pyproject.toml +1 -1
  7. requirements.txt +2 -1
  8. static/keyboard.js +1252 -247
  9. static/styles.css +776 -300
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎹
4
  colorFrom: purple
5
  colorTo: blue
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
@@ -12,30 +12,9 @@ short_description: Browser-based MIDI keyboard with recording and synthesis
12
 
13
  # SYNTHIA
14
 
15
- A minimal, responsive browser-based MIDI keyboard. Play live, record performances, and export as MIDI files. 🎹
16
 
17
- This build includes a **Godzilla** engine that can continue a short phrase using the
18
- Godzilla Piano Transformer.
19
-
20
-
21
- ## 🗂️ Project Structure
22
-
23
- ```
24
- .
25
- ├── app.py # Gradio server & API endpoints
26
- ├── config.py # Centralized configuration
27
- ├── engines.py # MIDI processing engines
28
- ├── midi_model.py # Godzilla model integration
29
- ├── midi.py # MIDI file utilities
30
- ├── keyboard.html # HTML structure
31
- ├── static/
32
- │ ├── keyboard.js # Client-side audio (Tone.js)
33
- │ └── styles.css # Styling & animations
34
- ├── requirements.txt # Python dependencies
35
- └── README.md # This file
36
- ```
37
-
38
- ## 🚀 Quick Start
39
 
40
  ```bash
41
  # Install dependencies
@@ -45,30 +24,212 @@ uv pip install -r requirements.txt
45
  uv run python app.py
46
  ```
47
 
48
- Open **http://127.0.0.1:7861**
49
 
50
- ## 🎹 Godzilla Engine
51
 
52
- Select **Godzilla** in the engine dropdown to generate a short continuation from your
53
- recorded phrase. The model is downloaded on first use and cached locally.
54
 
55
- Note: the engine filters generated notes to your on-screen keyboard range.
56
 
57
- ## 🌐 Deploy to Hugging Face Spaces
 
 
 
 
58
 
59
- ```bash
60
- git remote add hf git@hf.co:spaces/YOUR_USERNAME/synthia
61
- git push hf main
62
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- ## 🔧 Technology
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  - **Frontend**: Tone.js v6+ (Web Audio API)
67
- - **Backend**: Gradio 6.x + Python 3.10+
68
  - **MIDI**: mido library
69
  - **Model**: Godzilla Piano Transformer (via Hugging Face)
70
 
 
 
71
  ## 📝 License
72
 
73
  Open source - free to use and modify.
74
-
 
4
  colorFrom: purple
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
  short_description: Browser-based MIDI keyboard with recording and synthesis
 
12
 
13
  # SYNTHIA
14
 
15
+ Play, record, and let AI continue your musical phrases in real-time. 🎹
16
 
17
+ ## Quick Start
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  ```bash
20
  # Install dependencies
 
24
  uv run python app.py
25
  ```
26
 
27
+ Open **http://127.0.0.1:7860**
28
 
29
+ ---
30
 
31
+ ## 🏗️ Architecture Overview
 
32
 
33
+ **SYNTHIA** is a browser-based MIDI keyboard with three main layers:
34
 
35
+ 1. **Backend** (Python/Gradio): Configuration, MIDI engines, model loading
36
+ 2. **Frontend** (JavaScript/Tone.js): Audio synthesis, keyboard rendering, event handling
37
+ 3. **Communication**: Gradio bridge for sending recorded MIDI to backend for processing
38
+
39
+ ### Data Flow
40
 
 
 
 
41
  ```
42
+ User plays keyboard
43
+
44
+ JavaScript captures MIDI events → records to array
45
+
46
+ User clicks "Play/Process"
47
+
48
+ Backend engine processes recorded events
49
+
50
+ Result returned as MIDI events
51
+
52
+ JavaScript plays result through Tone.js synth
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 📂 File Responsibilities
58
+
59
+ ### Backend Files
60
+
61
+ | File | Purpose |
62
+ |------|---------|
63
+ | **app.py** | Gradio app setup, UI layout, instrument definitions, API endpoints |
64
+ | **config.py** | Global settings (audio parameters, model paths, inference defaults) |
65
+ | **engines.py** | Three MIDI processing engines: `parrot` (repeat), `reverse_parrot` (reverse), `godzilla_continue` (AI generation) |
66
+ | **midi_model.py** | Godzilla model loading, tokenization, inference |
67
+ | **midi.py** | MIDI file utilities (encode/decode, cleanup, utilities) |
68
+
69
+ ### Frontend Files
70
+
71
+ | File | Purpose |
72
+ |------|---------|
73
+ | **keyboard.html** | DOM structure (keyboard grid, controls, terminal) |
74
+ | **keyboard.js** | Main application logic: keyboard rendering, audio synthesis (Tone.js), recording, UI event binding, engine communication |
75
+ | **styles.css** | Styling and animations |
76
+
77
+ ### Configuration & Dependencies
78
+
79
+ | File | Purpose |
80
+ |------|---------|
81
+ | **requirements.txt** | Python dependencies |
82
+ | **pyproject.toml** | Project metadata |
83
+
84
+ ---
85
 
86
+ ## 🎹 Core Functionality
87
+
88
+ ### Keyboard Controls
89
+ - **Click keys** or **press computer keys** to play notes
90
+ - **Record button**: Capture MIDI events from keyboard
91
+ - **Play button**: Play back recorded events
92
+ - **Save button**: Download recording as .mid file
93
+ - **Game mode**: Take turns with AI completing phrases
94
+
95
+ ### MIDI Engines
96
+ 1. **Parrot**: Repeats your exact melody
97
+ 2. **Reverse Parrot**: Plays melody backward
98
+ 3. **Godzilla**: AI generates musical continuations using transformer model
99
+
100
+ ### UI Features
101
+ - **Engine selector**: Choose processing method
102
+ - **Style selector**: AI style (melodic, energetic, ornate, etc.)
103
+ - **Response mode**: Control AI generation behavior
104
+ - **Runtime selector**: GPU (fast) vs CPU (reliable)
105
+ - **Instrument selector**: Change synth sound
106
+ - **AI voice selector**: Change AI synth sound
107
+ - **Terminal**: Real-time event logging
108
+
109
+ ---
110
+
111
+ ## 🔧 How to Add New Functionality
112
+
113
+ ### Adding a New MIDI Engine
114
+
115
+ 1. **In `engines.py`**, add a new function:
116
+ ```python
117
+ def my_new_engine(events, options):
118
+ # Process MIDI events
119
+ return processed_events
120
+ ```
121
+
122
+ 2. **In `app.py`**, register the engine in `process_events()`:
123
+ ```python
124
+ elif engine == 'my_engine':
125
+ result_events = my_new_engine(events, options)
126
+ ```
127
+
128
+ 3. **In `app.py`**, add to engine dropdown:
129
+ ```python
130
+ with gr.Group(label="Engine"):
131
+ engine = gr.Dropdown(
132
+ choices=['parrot', 'reverse_parrot', 'godzilla_continue', 'my_engine'],
133
+ # ...
134
+ )
135
+ ```
136
+
137
+ 4. **In `keyboard.js`**, add tooltip (line ~215 in `populateEngineSelect()`):
138
+ ```javascript
139
+ const engineTooltips = {
140
+ 'my_engine': 'Description of what your engine does'
141
+ };
142
+ ```
143
+
144
+ ### Adding a New Control Selector
145
+
146
+ 1. **In `app.py`**, create the selector in the UI:
147
+ ```python
148
+ my_control = gr.Dropdown(
149
+ choices=['option1', 'option2'],
150
+ label="My Control",
151
+ value='option1'
152
+ )
153
+ ```
154
+
155
+ 2. **In `keyboard.js`** (line ~1510), add to `selectControls` array:
156
+ ```javascript
157
+ {
158
+ element: myControlSelect,
159
+ getter: () => ({ label: myControlSelect.value }),
160
+ message: (result) => `Control switched to: ${result.label}`
161
+ }
162
+ ```
163
+
164
+ 3. **In `keyboard.js`**, pass control to engine via `processEventsThroughEngine()`:
165
+ ```javascript
166
+ const engineOptions = {
167
+ my_control: document.getElementById('myControl').value,
168
+ // ... other options
169
+ };
170
+ ```
171
+
172
+ ### Adding a New Response Mode
173
+
174
+ 1. **In `keyboard.js`** (line ~175), add preset definition:
175
+ ```javascript
176
+ const RESPONSE_MODES = {
177
+ 'my_mode': {
178
+ label: 'My Mode',
179
+ processFunction: (events) => {
180
+ // Processing logic
181
+ return processedEvents;
182
+ }
183
+ }
184
+ };
185
+ ```
186
+
187
+ 2. **In `app.py`**, add to response mode dropdown
188
+
189
+ 3. **Use in engine logic** via `getSelectedResponseMode()`
190
+
191
+ ---
192
+
193
+ ## 🔄 Recent Refactoring (Feb 2026)
194
+
195
+ Code consolidation to improve maintainability:
196
+
197
+ - **Consolidated getter functions**: Single `getSelectedPreset()` replaces 3 similar functions
198
+ - **Unified event listeners**: Loop-based pattern for select controls (runtime, style, mode, length)
199
+ - **Extracted helper functions**: `resetAllNotesAndVisuals()` replaces 3 duplicated blocks
200
+ - **Result**: Reduced redundancy, easier to modify preset logic, consistent patterns
201
+
202
+ ---
203
+
204
+ ## 🛠️ Development Tips
205
+
206
+ ### Debugging
207
+ - **Terminal in UI**: Shows all MIDI events and engine responses
208
+ - **Browser console**: `F12` for JavaScript errors
209
+ - **Python terminal**: Check server-side logs for model loading, inference errors
210
+
211
+ ### Testing New Engines
212
+ 1. Record a simple 3-5 note progression
213
+ 2. Play back with different engines
214
+ 3. Check terminal for processing details
215
+ 4. Verify output notes are in valid range (0-127)
216
+
217
+ ### Performance
218
+ - **Recording**: Event capture happens in JavaScript (fast, local)
219
+ - **Processing**: May take 2-5 seconds depending on engine and model
220
+ - **Playback**: Tone.js synthesis is real-time (instant)
221
+
222
+ ---
223
+
224
+ ## 🔧 Technology Stack
225
 
226
  - **Frontend**: Tone.js v6+ (Web Audio API)
227
+ - **Backend**: Gradio 5.49.1 + Python 3.10+
228
  - **MIDI**: mido library
229
  - **Model**: Godzilla Piano Transformer (via Hugging Face)
230
 
231
+ ---
232
+
233
  ## 📝 License
234
 
235
  Open source - free to use and modify.
 
app.py CHANGED
@@ -10,12 +10,19 @@ A browser-based MIDI keyboard that can:
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
  # =============================================================================
@@ -33,6 +40,15 @@ def save_midi_api(events):
33
  return {"midi_base64": midi_b64}
34
 
35
 
 
 
 
 
 
 
 
 
 
36
  def get_config():
37
  """Provide frontend with instruments and keyboard layout"""
38
  return {
@@ -45,26 +61,128 @@ def get_config():
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  try:
55
  engine = EngineRegistry.get_engine(engine_id)
56
- processed = engine.process(events)
 
 
 
 
 
57
  return {"success": True, "events": processed}
58
  except ValueError as e:
 
59
  return {"error": str(e)}
60
  except Exception as e:
 
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()
@@ -75,58 +193,75 @@ with open("static/styles.css", "r", encoding="utf-8") as f:
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>",
82
- ).replace(
83
- '<script src="/file=static/keyboard.js"></script>', f"<script>{js_content}</script>"
84
- )
85
 
86
- iframe_html = (
87
- '<iframe srcdoc="'
88
- + html.escape(keyboard_html, quote=True)
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",
130
  )
131
 
132
 
 
10
  """
11
 
12
  import base64
13
+ import json
14
+ import os
15
+ import re
16
+ from threading import Thread
17
+ import traceback
18
  import gradio as gr
19
+ from huggingface_hub import login
20
 
21
  from config import INSTRUMENTS, KEYBOARD_KEYS, KEYBOARD_SHORTCUTS
22
  from midi import events_to_midbytes
23
+ from midi_model import preload_godzilla_assets, preload_godzilla_model
24
  from engines import EngineRegistry
25
+ import spaces
26
 
27
 
28
  # =============================================================================
 
40
  return {"midi_base64": midi_b64}
41
 
42
 
43
+ def _parse_json_payload(payload_text: str | None, default):
44
+ if payload_text is None or payload_text == "":
45
+ return default
46
+ try:
47
+ return json.loads(payload_text)
48
+ except json.JSONDecodeError:
49
+ return default
50
+
51
+
52
  def get_config():
53
  """Provide frontend with instruments and keyboard layout"""
54
  return {
 
61
  ],
62
  }
63
 
64
+ def process_with_engine(
65
+ engine_id: str,
66
+ events: list,
67
+ options: dict | None = None,
68
+ request: "gr.Request | None" = None,
69
+ device: str = "auto",
70
+ ):
71
  """Process MIDI events through selected engine"""
72
  if not engine_id or not events:
73
  return {"error": "Missing engine_id or events"}
74
 
75
+ x_ip_token = (
76
+ request.headers.get("x-ip-token")
77
+ if request is not None and hasattr(request, "headers")
78
+ else None
79
+ )
80
+ print(
81
+ "process_engine auth:",
82
+ {
83
+ "engine_id": engine_id,
84
+ "has_x_ip_token": bool(x_ip_token),
85
+ },
86
+ )
87
+
88
  try:
89
  engine = EngineRegistry.get_engine(engine_id)
90
+ processed = engine.process(
91
+ events,
92
+ options=options,
93
+ request=request,
94
+ device=device,
95
+ )
96
  return {"success": True, "events": processed}
97
  except ValueError as e:
98
+ traceback.print_exc()
99
  return {"error": str(e)}
100
  except Exception as e:
101
+ traceback.print_exc()
102
  return {"error": f"Processing error: {str(e)}"}
103
 
104
 
105
+ def process_engine_payload(
106
+ payload: dict,
107
+ request: "gr.Request | None" = None,
108
+ device: str = "auto",
109
+ ):
110
+ if not isinstance(payload, dict):
111
+ return {"error": "Invalid payload"}
112
+ return process_with_engine(
113
+ payload.get("engine_id"),
114
+ payload.get("events", []),
115
+ options=payload.get("options"),
116
+ request=request,
117
+ device=device,
118
+ )
119
+
120
+
121
+ def config_event_bridge(_payload_text: str) -> str:
122
+ return json.dumps(get_config())
123
+
124
+
125
+ def save_midi_event_bridge(payload_text: str) -> str:
126
+ events = _parse_json_payload(payload_text, [])
127
+ result = save_midi_api(events)
128
+ return json.dumps(result)
129
+
130
+
131
+ def process_engine_event_bridge_cpu(
132
+ payload_text: str,
133
+ request: "gr.Request | None" = None,
134
+ ) -> str:
135
+ payload = _parse_json_payload(payload_text, {})
136
+ result = process_engine_payload(payload, request=request, device="cpu")
137
+ return json.dumps(result)
138
+
139
+
140
+ @spaces.GPU(duration=120)
141
+ def process_engine_event_bridge_gpu(
142
+ payload_text: str,
143
+ request: "gr.Request | None" = None,
144
+ ) -> str:
145
+ payload = _parse_json_payload(payload_text, {})
146
+ result = process_engine_payload(payload, request=request, device="cuda")
147
+ return json.dumps(result)
148
+
149
+
150
+ def start_background_preload() -> None:
151
+ def _run() -> None:
152
+ try:
153
+ checkpoint_path = preload_godzilla_assets()
154
+ print(f"Godzilla assets preloaded: {checkpoint_path}")
155
+ model_info = preload_godzilla_model(device="cpu")
156
+ print(f"Godzilla model warmed in memory: {model_info}")
157
+ except Exception:
158
+ print("Godzilla preload failed:")
159
+ traceback.print_exc()
160
+
161
+ Thread(target=_run, daemon=True).start()
162
+
163
+
164
+ def login_huggingface_from_env() -> None:
165
+ """Authenticate with Hugging Face if HF_TOKEN is available."""
166
+ token = os.environ.get("HF_TOKEN")
167
+ if not token:
168
+ print("HF_TOKEN not set; skipping huggingface_hub.login()")
169
+ return
170
+
171
+ try:
172
+ login(token=token, add_to_git_credential=False)
173
+ print("Authenticated with Hugging Face using HF_TOKEN")
174
+ except Exception:
175
+ print("huggingface_hub login failed:")
176
+ traceback.print_exc()
177
+
178
+
179
  # =============================================================================
180
  # GRADIO UI
181
  # =============================================================================
182
 
183
+ login_huggingface_from_env()
184
+ start_background_preload()
185
+
186
  # Load HTML and CSS
187
  with open("keyboard.html", "r", encoding="utf-8") as f:
188
  html_content = f.read()
 
193
  with open("static/keyboard.js", "r", encoding="utf-8") as f:
194
  js_content = f.read()
195
 
196
+ body_match = re.search(r"<body[^>]*>(.*)</body>", html_content, flags=re.IGNORECASE | re.DOTALL)
197
+ keyboard_markup = body_match.group(1) if body_match else html_content
198
+ keyboard_markup = re.sub(r"<script\b[^>]*>.*?</script>", "", keyboard_markup, flags=re.IGNORECASE | re.DOTALL)
199
+ keyboard_markup = re.sub(r"<link\b[^>]*>", "", keyboard_markup, flags=re.IGNORECASE)
 
 
 
200
 
201
+ # Make logo rendering robust by embedding local repo logo bytes directly.
202
+ logo_path = "synthia_logo.png"
203
+ if os.path.exists(logo_path):
204
+ try:
205
+ with open(logo_path, "rb") as logo_file:
206
+ logo_b64 = base64.b64encode(logo_file.read()).decode("ascii")
207
+ keyboard_markup = keyboard_markup.replace(
208
+ 'src="/file=synthia_logo.png"',
209
+ f'src="data:image/png;base64,{logo_b64}"',
210
+ )
211
+ except Exception:
212
+ print("Failed to embed synthia_logo.png; keeping original src path.")
213
+ traceback.print_exc()
214
+ else:
215
+ print("synthia_logo.png not found; logo image may not render.")
216
+
217
+ hidden_bridge_css = "\n.vk-hidden { display: none !important; }\n"
218
+ head_html = "\n".join(
219
+ [
220
+ '<script src="https://unpkg.com/tone@next/build/Tone.js"></script>',
221
+ f"<script>{js_content}</script>",
222
+ ]
223
  )
224
 
225
  # Create Gradio app
226
+ with gr.Blocks(title="Virtual MIDI Keyboard", css=css_content + hidden_bridge_css, head=head_html) as demo:
227
+ gr.HTML(keyboard_markup)
228
+
229
+ # Hidden bridges for direct Gradio event calls from frontend JS
230
+ with gr.Group(elem_classes=["vk-hidden"]):
231
+ config_input = gr.Textbox(value="{}", elem_id="vk_config_input", show_label=False)
232
+ config_output = gr.Textbox(elem_id="vk_config_output", show_label=False)
233
+ config_btn = gr.Button("get_config", elem_id="vk_config_btn")
234
  config_btn.click(
235
+ fn=config_event_bridge,
236
  inputs=config_input,
237
  outputs=config_output,
 
238
  )
239
 
240
+ midi_input = gr.Textbox(value="[]", elem_id="vk_save_input", show_label=False)
241
+ midi_output = gr.Textbox(elem_id="vk_save_output", show_label=False)
242
+ midi_btn = gr.Button("save_midi", elem_id="vk_save_btn")
 
243
  midi_btn.click(
244
+ fn=save_midi_event_bridge,
245
  inputs=midi_input,
246
  outputs=midi_output,
 
247
  )
248
 
249
+ engine_input = gr.Textbox(value="{}", elem_id="vk_engine_input", show_label=False)
250
+
251
+ engine_cpu_output = gr.Textbox(elem_id="vk_engine_cpu_output", show_label=False)
252
+ engine_cpu_btn = gr.Button("process_engine_cpu", elem_id="vk_engine_cpu_btn")
253
+ engine_cpu_btn.click(
254
+ fn=process_engine_event_bridge_cpu,
255
+ inputs=engine_input,
256
+ outputs=engine_cpu_output,
257
+ )
258
+
259
+ engine_gpu_output = gr.Textbox(elem_id="vk_engine_gpu_output", show_label=False)
260
+ engine_gpu_btn = gr.Button("process_engine_gpu", elem_id="vk_engine_gpu_btn")
261
+ engine_gpu_btn.click(
262
+ fn=process_engine_event_bridge_gpu,
263
  inputs=engine_input,
264
+ outputs=engine_gpu_output,
 
265
  )
266
 
267
 
engines.py CHANGED
@@ -8,7 +8,7 @@ from typing import List, Dict, Any
8
 
9
  from midi_model import (
10
  count_out_of_range_events,
11
- filter_events_to_keyboard_range,
12
  get_model,
13
  )
14
 
@@ -28,7 +28,13 @@ class ParrotEngine:
28
  def __init__(self):
29
  self.name = "Parrot"
30
 
31
- def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
 
 
 
 
32
  """Return events unchanged"""
33
  if not events:
34
  return []
@@ -60,7 +66,13 @@ class ReverseParrotEngine:
60
  def __init__(self):
61
  self.name = "Reverse Parrot"
62
 
63
- def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
 
 
 
 
64
  """Reverse the sequence of note numbers while keeping timing and event types"""
65
  if not events:
66
  return []
@@ -125,20 +137,53 @@ class GodzillaContinuationEngine:
125
  self.name = "Godzilla"
126
  self.generate_tokens = generate_tokens
127
 
128
- def process(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
 
 
 
 
129
  if not events:
130
  return []
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  model = get_model("godzilla")
133
  new_events = model.generate_continuation(
134
  events,
135
- tokens=self.generate_tokens,
136
- seed=None,
 
 
 
 
 
137
  )
138
  out_of_range = count_out_of_range_events(new_events)
139
  if out_of_range:
140
- print(f"Godzilla: dropped {out_of_range} out-of-range events")
141
- return filter_events_to_keyboard_range(new_events)
142
 
143
 
144
  # =============================================================================
 
8
 
9
  from midi_model import (
10
  count_out_of_range_events,
11
+ fold_events_to_keyboard_range,
12
  get_model,
13
  )
14
 
 
28
  def __init__(self):
29
  self.name = "Parrot"
30
 
31
+ def process(
32
+ self,
33
+ events: List[Dict[str, Any]],
34
+ options: Dict[str, Any] | None = None,
35
+ request: Any | None = None,
36
+ device: str = "auto",
37
+ ) -> List[Dict[str, Any]]:
38
  """Return events unchanged"""
39
  if not events:
40
  return []
 
66
  def __init__(self):
67
  self.name = "Reverse Parrot"
68
 
69
+ def process(
70
+ self,
71
+ events: List[Dict[str, Any]],
72
+ options: Dict[str, Any] | None = None,
73
+ request: Any | None = None,
74
+ device: str = "auto",
75
+ ) -> List[Dict[str, Any]]:
76
  """Reverse the sequence of note numbers while keeping timing and event types"""
77
  if not events:
78
  return []
 
137
  self.name = "Godzilla"
138
  self.generate_tokens = generate_tokens
139
 
140
+ def process(
141
+ self,
142
+ events: List[Dict[str, Any]],
143
+ options: Dict[str, Any] | None = None,
144
+ request: Any | None = None,
145
+ device: str = "auto",
146
+ ) -> List[Dict[str, Any]]:
147
  if not events:
148
  return []
149
 
150
+ generate_tokens = self.generate_tokens
151
+ seed = None
152
+ temperature = 0.9
153
+ top_p = 0.95
154
+ num_candidates = 3
155
+ if isinstance(options, dict):
156
+ requested_tokens = options.get("generate_tokens")
157
+ if isinstance(requested_tokens, int):
158
+ generate_tokens = max(8, min(256, requested_tokens))
159
+ requested_seed = options.get("seed")
160
+ if isinstance(requested_seed, int):
161
+ seed = requested_seed
162
+ requested_temperature = options.get("temperature")
163
+ if isinstance(requested_temperature, (int, float)):
164
+ temperature = max(0.2, min(1.5, float(requested_temperature)))
165
+ requested_top_p = options.get("top_p")
166
+ if isinstance(requested_top_p, (int, float)):
167
+ top_p = max(0.5, min(0.99, float(requested_top_p)))
168
+ requested_candidates = options.get("num_candidates")
169
+ if isinstance(requested_candidates, int):
170
+ num_candidates = max(1, min(6, requested_candidates))
171
+
172
  model = get_model("godzilla")
173
  new_events = model.generate_continuation(
174
  events,
175
+ tokens=generate_tokens,
176
+ seed=seed,
177
+ temperature=temperature,
178
+ top_p=top_p,
179
+ num_candidates=num_candidates,
180
+ request=request,
181
+ device=device,
182
  )
183
  out_of_range = count_out_of_range_events(new_events)
184
  if out_of_range:
185
+ print(f"Godzilla: remapped {out_of_range} out-of-range events by octave folding")
186
+ return fold_events_to_keyboard_range(new_events)
187
 
188
 
189
  # =============================================================================
keyboard.html CHANGED
@@ -7,60 +7,189 @@
7
  <link rel="stylesheet" href="/file=static/styles.css" />
8
  </head>
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">
16
- <!-- Keyboard Section -->
17
- <div class="keyboard-section">
18
- <div id="keyboard"></div>
19
-
20
- <div class="controls">
21
- <label>
22
- Instrument:
23
- <select id="instrumentSelect">
24
- <option value="synth">Synth</option>
25
- <option value="piano">Piano</option>
26
- <option value="organ">Organ</option>
27
- <option value="bass">Bass</option>
28
- <option value="pluck">Pluck</option>
29
- <option value="fm">FM Synth</option>
30
- </select>
31
- </label>
32
-
33
- <label>
34
- Engine:
35
- <select id="engineSelect">
36
- <option value="parrot">Parrot</option>
37
- <option value="reverse_parrot">Reverse Parrot</option>
38
- <option value="godzilla_continue">Godzilla</option>
39
- </select>
40
- </label>
41
 
42
- <button id="recordBtn">Record</button>
43
- <button id="stopBtn" disabled>Stop</button>
44
- <button id="playbackBtn" disabled>Playback</button>
45
- <button id="saveBtn" disabled>Save MIDI</button>
46
- <button id="panicBtn" style="background: #ff4444; color: white;">Panic 🚨</button>
47
-
48
- <label>
49
- <input type="checkbox" id="keyboardToggle">
50
- Enable Keyboard Input
51
- </label>
52
-
53
- <span id="status">Idle</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  </div>
55
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- <!-- MIDI Monitor Section -->
58
- <div class="monitor-section">
59
- <div class="terminal-header">
60
- <h4>MIDI MONITOR</h4>
61
- <button id="clearTerminal">Clear</button>
 
 
 
 
62
  </div>
63
- <div id="terminal"></div>
64
  </div>
65
  </div>
66
 
 
7
  <link rel="stylesheet" href="/file=static/styles.css" />
8
  </head>
9
  <body>
10
+ <div class="app-shell">
11
+ <div class="welcome-header">
12
+ <h1><span class="synth">SYNTH</span><i class="ia">IA</i></h1>
13
+
14
+ <div class="welcome-text">
15
+ <p class="welcome-intro">
16
+ Play a short phrase and let the AI respond musically. Choose your instrument, AI voice, and response style.
17
+ </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ <div class="features-guide" style="display: none;">
20
+ <h3>Features</h3>
21
+ <ul class="features-list">
22
+ <li><strong>Game Mode:</strong> Start an interactive call-and-response session where the AI listens to your playing and responds musically</li>
23
+
24
+ <li><strong>Instrument:</strong> Choose the synth sound and effects for your keyboard—Synth, Piano, Organ, Bass, Pluck, or FM Synth create different tonal qualities</li>
25
+
26
+ <li><strong>AI Voice:</strong> Select which instrument sound the AI uses when it responds to your playing</li>
27
+
28
+ <li><strong>Engine:</strong> Choose how the AI responds:
29
+ <ul class="sub-list">
30
+ <li><em>Parrot:</em> Repeats exactly what you played</li>
31
+ <li><em>Reverse Parrot:</em> Plays your notes in reverse order (backward)</li>
32
+ <li><em>Godzilla:</em> Uses AI to generate a musically coherent continuation of your phrase</li>
33
+ </ul>
34
+ </li>
35
+
36
+ <li><strong>Runtime:</strong> Select where the AI runs—CPU (slower, always available), ZeroGPU (faster if available), or Auto (prefers GPU, falls back to CPU)</li>
37
+
38
+ <li><strong>AI Style:</strong> Post-processing that shapes the AI's melodic style (Godzilla engine only):
39
+ <ul class="sub-list">
40
+ <li><em>Melodic:</em> Smooth, singable melodies with minimal pitch leaps</li>
41
+ <li><em>Motif Echo:</em> Echoes the first couple notes from your input at the end of the response</li>
42
+ <li><em>Playful:</em> Adds bouncy alternating pitch shifts for a whimsical character</li>
43
+ </ul>
44
+ </li>
45
+
46
+ <li><strong>Response Mode:</strong> Determines the processing pipeline and sampling creativity (Godzilla engine only):
47
+ <ul class="sub-list">
48
+ <li><em>Raw Godzilla:</em> Pure AI output with maximum creativity (temperature=1.0, more unpredictable)</li>
49
+ <li><em>Current Pipeline:</em> Balanced mode applying your AI Style with moderate creativity (temperature=0.9, best of 3 candidates)</li>
50
+ <li><em>Musical Polish:</em> Refined mode with advanced processing—quantizes pitches to musical scales, adjusts rhythm, blends velocities, and ensures musical coherence (temperature=0.85, picks best of 4)</li>
51
+ </ul>
52
+ </li>
53
+
54
+ <li><strong>Response Length:</strong> How many notes the AI generates—Short (8 notes), Medium (14 notes), Long (20 notes), or Extended (28 notes)</li>
55
+
56
+ <li><strong>Record/Playback:</strong> Record your keyboard performance and play it back to hear it again</li>
57
+
58
+ <li><strong>Save MIDI:</strong> Export your recordings as MIDI files for use in DAWs or other music software</li>
59
+ </ul>
60
+ </div>
61
  </div>
62
  </div>
63
+
64
+ <div id="mainContainer">
65
+ <div class="keyboard-section card">
66
+ <div id="keyboard"></div>
67
+ <div class="keyboard-toggle-row">
68
+ <label class="keyboard-toggle-pill">
69
+ <input type="checkbox" id="keyboardToggle">
70
+ <span class="toggle-track"></span>
71
+ <span class="toggle-text">Enable Keyboard Input</span>
72
+ </label>
73
+ <div class="tooltip-display-area" id="tooltipDisplay"></div>
74
+ </div>
75
+
76
+ <div class="controls card">
77
+ <div class="control-grid">
78
+ <label class="control-item" data-control-id="instrument">
79
+ Instrument
80
+ <select id="instrumentSelect" data-description="Choose the synth sound for your keyboard">
81
+ <option value="synth" data-tooltip="Bright, modern electronic sound">Synth</option>
82
+ <option value="piano" data-tooltip="Classic acoustic piano tone">Piano</option>
83
+ <option value="organ" data-tooltip="Rich, warm organ pipes">Organ</option>
84
+ <option value="bass" data-tooltip="Deep, resonant bass tones">Bass</option>
85
+ <option value="pluck" data-tooltip="Percussive plucked string sound">Pluck</option>
86
+ <option value="fm" data-tooltip="Complex, bell-like FM synthesis">FM Synth</option>
87
+ </select>
88
+ </label>
89
+
90
+ <label class="control-item" data-control-id="aiVoice">
91
+ AI Voice
92
+ <select id="aiInstrumentSelect" data-description="Select which instrument sound the AI uses when it responds">
93
+ <option value="synth" data-tooltip="Bright, modern electronic sound">Synth</option>
94
+ <option value="piano" data-tooltip="Classic acoustic piano tone">Piano</option>
95
+ <option value="organ" data-tooltip="Rich, warm organ pipes">Organ</option>
96
+ <option value="bass" data-tooltip="Deep, resonant bass tones">Bass</option>
97
+ <option value="pluck" data-tooltip="Percussive plucked string sound">Pluck</option>
98
+ <option value="fm" selected data-tooltip="Complex, bell-like FM synthesis">FM Synth</option>
99
+ </select>
100
+ </label>
101
+
102
+ <label class="control-item" data-control-id="engine">
103
+ Engine
104
+ <select id="engineSelect" data-description="Your generation model">
105
+ <option value="parrot" data-tooltip="Repeats your exact melody — useful for practicing or creating canon patterns">Parrot</option>
106
+ <option value="reverse_parrot" data-tooltip="Plays your melody backward — creates mirror images and harmonic inversions">Reverse Parrot</option>
107
+ <option value="godzilla_continue" data-tooltip="MIDI transformer that generates new musical continuations">Godzilla</option>
108
+ </select>
109
+ </label>
110
+
111
+ <label class="control-item" id="runtimeControl" data-control-id="runtime">
112
+ Runtime
113
+ <select id="runtimeSelect" data-description="Choose where the AI runs for faster or more reliable performance">
114
+ <option value="cpu" data-tooltip="Slower but always available, runs on CPU">CPU</option>
115
+ <option value="gpu" data-tooltip="Faster when available, uses ZeroGPU for inference">ZeroGPU</option>
116
+ <option value="auto" data-tooltip="Automatically prefers GPU if available, falls back to CPU" selected>Auto (GPU then CPU)</option>
117
+ </select>
118
+ </label>
119
+
120
+ <label class="control-item" data-control-id="aiStyle">
121
+ AI Style
122
+ <select id="responseStyleSelect" data-description="Applies post-processing styling to shape the AI's melodic character">
123
+ <option value="melodic" data-tooltip="Smooth, singable melodies with minimal pitch leaps">Melodic</option>
124
+ <option value="motif_echo" data-tooltip="Echoes the first couple notes from your input at the end of the response">Motif Echo</option>
125
+ <option value="playful" data-tooltip="Bouncy alternating pitch shifts for a whimsical, playful character">Playful</option>
126
+ </select>
127
+ </label>
128
+
129
+ <label class="control-item" data-control-id="responseMode">
130
+ Response Mode
131
+ <select id="responseModeSelect" data-description="Processing pipeline & creativity level">
132
+ <option value="raw_godzilla" data-tooltip="Pure AI output, maximum creativity (temperature=1.0, unpredictable)" selected>Raw Godzilla</option>
133
+ <option value="current_pipeline" data-tooltip="Balanced mode with AI Style applied, moderate creativity (temperature=0.9)">Current Pipeline</option>
134
+ <option value="musical_polish" data-tooltip="Refined with scale quantization and rhythm adjustment (temperature=0.85)">Musical Polish</option>
135
+ </select>
136
+ </label>
137
+
138
+ <label class="control-item" data-control-id="responseLength">
139
+ Response Length
140
+ <select id="responseLengthSelect" data-description="How many notes the AI generates in each response">
141
+ <option value="short" data-tooltip="8 notes - quick, snappy responses" selected>Short</option>
142
+ <option value="medium" data-tooltip="14 notes - balanced response length">Medium</option>
143
+ <option value="long" data-tooltip="20 notes - longer, more developed phrases">Long</option>
144
+ <option value="extended" data-tooltip="28 notes - extended musical conversations">Extended</option>
145
+ </select>
146
+ </label>
147
+ </div>
148
+
149
+ <div class="action-row">
150
+ <div class="btn-tooltip-wrapper">
151
+ <button id="recordBtn" class="btn btn-primary">Record</button>
152
+ <div class="btn-tooltip">Record your playing to MIDI. Click Record, play, then Stop.</div>
153
+ </div>
154
+ <div class="btn-tooltip-wrapper">
155
+ <button id="stopBtn" class="btn btn-secondary" disabled>Stop</button>
156
+ <div class="btn-tooltip">Stop recording your performance.</div>
157
+ </div>
158
+ <div class="btn-tooltip-wrapper">
159
+ <button id="playbackBtn" class="btn btn-secondary" disabled>Playback</button>
160
+ <div class="btn-tooltip">Play back your recorded performance.</div>
161
+ </div>
162
+ <div class="btn-tooltip-wrapper">
163
+ <button id="gameStartBtn" class="btn btn-game">Start Game</button>
164
+ <div class="btn-tooltip">Start call & response: play a phrase, AI responds, repeat.</div>
165
+ </div>
166
+ <div class="btn-tooltip-wrapper">
167
+ <button id="gameStopBtn" class="btn btn-secondary" disabled>Stop Game</button>
168
+ <div class="btn-tooltip">Stop the ongoing game session.</div>
169
+ </div>
170
+ <div class="btn-tooltip-wrapper">
171
+ <button id="saveBtn" class="btn btn-secondary" disabled>Save MIDI</button>
172
+ <div class="btn-tooltip">Export your recording as a MIDI file.</div>
173
+ </div>
174
+ <div class="btn-tooltip-wrapper">
175
+ <button id="panicBtn" class="btn btn-danger">Panic</button>
176
+ <div class="btn-tooltip">Stop all playing notes immediately.</div>
177
+ </div>
178
+ <span id="status">Idle</span>
179
+ </div>
180
+ </div>
181
+ </div>
182
 
183
+ <div class="monitor-section card">
184
+ <div class="terminal-header">
185
+ <h4>MIDI MONITOR</h4>
186
+ <div class="btn-tooltip-wrapper">
187
+ <button id="clearTerminal" class="btn btn-secondary">Clear</button>
188
+ <div class="btn-tooltip">Clear all MIDI events from the monitor.</div>
189
+ </div>
190
+ </div>
191
+ <div id="terminal"></div>
192
  </div>
 
193
  </div>
194
  </div>
195
 
midi_model.py CHANGED
@@ -3,20 +3,37 @@ from __future__ import annotations
3
 
4
  import subprocess
5
  import sys
 
 
 
6
  from dataclasses import dataclass
7
  from pathlib import Path
8
- from typing import Iterable, Optional
9
 
10
  from config import MIDI_DEFAULTS, KEYBOARD_BASE_MIDI, KEYBOARD_OCTAVES
11
 
12
-
13
  DEFAULT_REPO = "asigalov61/Godzilla-Piano-Transformer"
14
  DEFAULT_FILENAME = (
15
- "Godzilla_Piano_Chords_Texturing_Transformer_Trained_Model_22708_steps_"
16
- "0.7515_loss_0.7853_acc.pth"
17
  )
 
 
 
 
 
 
18
 
19
  _MODEL_CACHE: dict[str, object] = {}
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  @dataclass(frozen=True)
@@ -30,6 +47,11 @@ class MidiModel:
30
  *,
31
  tokens: int = 32,
32
  seed: Optional[int] = None,
 
 
 
 
 
33
  ) -> list[dict]:
34
  raise NotImplementedError
35
 
@@ -59,6 +81,99 @@ def ensure_tegridy_tools(base_dir: Path) -> tuple[Path, Path]:
59
  return tools_dir, x_transformer_dir
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def add_sys_path(*paths: Path) -> None:
63
  for path in paths:
64
  path_str = str(path.resolve())
@@ -66,16 +181,23 @@ def add_sys_path(*paths: Path) -> None:
66
  sys.path.insert(0, path_str)
67
 
68
 
69
- def build_model(seq_len: int, pad_idx: int):
 
 
 
 
 
 
 
70
  from x_transformer_2_3_1 import AutoregressiveWrapper, Decoder, TransformerWrapper
71
 
72
  model = TransformerWrapper(
73
  num_tokens=pad_idx + 1,
74
  max_seq_len=seq_len,
75
  attn_layers=Decoder(
76
- dim=2048,
77
- depth=8,
78
- heads=32,
79
  rotary_pos_emb=True,
80
  attn_flash=True,
81
  ),
@@ -91,18 +213,193 @@ def resolve_device(requested: str) -> str:
91
  return requested
92
 
93
 
94
- def load_checkpoint(model, checkpoint_path: Path, device: str) -> None:
95
  import torch
96
 
97
  state = torch.load(checkpoint_path, map_location=device)
98
- model.load_state_dict(state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
 
101
  def events_to_score_tokens(events: list[dict]) -> list[int]:
102
  if not events:
103
  return []
104
 
105
- active: dict[int, float] = {}
106
  notes: list[tuple[float, float, int]] = []
107
  sorted_events = sorted(events, key=lambda e: e.get("time", 0.0))
108
 
@@ -113,11 +410,11 @@ def events_to_score_tokens(events: list[dict]) -> list[int]:
113
  time_sec = float(event.get("time", 0.0))
114
 
115
  if ev_type == "note_on" and velocity > 0:
116
- active[note] = time_sec
117
  elif ev_type in {"note_off", "note_on"}:
118
- if note in active:
119
- start = active.pop(note)
120
- duration = max(0.0, time_sec - start)
121
  notes.append((start, duration, note))
122
 
123
  if not notes:
@@ -212,14 +509,139 @@ def filter_events_to_keyboard_range(events: list[dict]) -> list[dict]:
212
  ]
213
 
214
 
215
- def build_prime_tokens(score_tokens: list[int], seq_len: int) -> list[int]:
216
- prime = [705, 384, 706]
217
- if score_tokens:
218
- max_score = max(0, seq_len - len(prime))
219
- prime.extend(score_tokens[-max_score:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  else:
221
- prime.extend([0, 129, 316])
222
- return prime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
 
225
  def load_model_cached(
@@ -231,28 +653,16 @@ def load_model_cached(
231
  seq_len: int,
232
  pad_idx: int,
233
  device: str,
234
- ) -> tuple[object, str, Path]:
235
- from huggingface_hub import hf_hub_download
236
  import torch
237
 
238
  cache_dir.mkdir(parents=True, exist_ok=True)
239
  resolved_device = resolve_device(device)
240
- cache_key = f"{repo}:{filename}:{seq_len}:{pad_idx}:{resolved_device}"
241
 
242
- if _MODEL_CACHE.get("key") == cache_key:
243
- return (
244
- _MODEL_CACHE["model"],
245
- _MODEL_CACHE["device"],
246
- _MODEL_CACHE["tools_dir"],
247
- )
248
-
249
- checkpoint_path = Path(
250
- hf_hub_download(
251
- repo_id=repo,
252
- filename=filename,
253
- local_dir=str(cache_dir),
254
- repo_type="model",
255
- )
256
  )
257
 
258
  tools_dir, x_transformer_dir = ensure_tegridy_tools(tegridy_dir)
@@ -263,18 +673,91 @@ def load_model_cached(
263
  torch.backends.cuda.matmul.allow_tf32 = True
264
  torch.backends.cudnn.allow_tf32 = True
265
 
266
- model = build_model(seq_len, pad_idx)
267
- load_checkpoint(model, checkpoint_path, resolved_device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  model.to(resolved_device)
269
  model.eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
  _MODEL_CACHE["key"] = cache_key
272
  _MODEL_CACHE["model"] = model
273
  _MODEL_CACHE["device"] = resolved_device
274
  _MODEL_CACHE["tools_dir"] = tools_dir
275
  _MODEL_CACHE["checkpoint_path"] = checkpoint_path
 
 
276
 
277
- return model, resolved_device, tools_dir
278
 
279
 
280
  def generate_from_events(
@@ -282,6 +765,9 @@ def generate_from_events(
282
  *,
283
  generate_tokens: int,
284
  seed: int | None,
 
 
 
285
  repo: str,
286
  filename: str,
287
  cache_dir: Path,
@@ -292,7 +778,7 @@ def generate_from_events(
292
  ) -> tuple[list[dict], list[int]]:
293
  import torch
294
 
295
- model, resolved_device, _ = load_model_cached(
296
  repo=repo,
297
  filename=filename,
298
  cache_dir=cache_dir,
@@ -302,31 +788,79 @@ def generate_from_events(
302
  device=device,
303
  )
304
 
305
- if seed is not None:
306
- torch.manual_seed(seed)
307
- if resolved_device == "cuda":
308
- torch.cuda.manual_seed_all(seed)
309
-
310
- score_tokens = events_to_score_tokens(events)
311
- prime = build_prime_tokens(score_tokens, seq_len)
312
  prime_tensor = torch.tensor(prime, dtype=torch.long, device=resolved_device)
313
-
314
- out = model.generate(
315
- prime_tensor,
316
- generate_tokens,
317
- return_prime=True,
318
- eos_token=707,
319
- )
320
-
321
- tokens = out.detach().cpu().tolist()
322
- new_tokens = tokens[len(prime) :]
323
 
324
  last_time_ms = 0.0
325
  if events:
326
  last_time_ms = max(float(e.get("time", 0.0)) for e in events) * 1000.0
327
 
328
- new_events = tokens_to_events(new_tokens, offset_ms=last_time_ms)
329
- return new_events, new_tokens
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
 
332
  def generate_godzilla_continuation(
@@ -334,12 +868,19 @@ def generate_godzilla_continuation(
334
  *,
335
  generate_tokens: int = 32,
336
  seed: int | None = None,
 
 
 
337
  device: str = "auto",
 
338
  ) -> tuple[list[dict], list[int]]:
339
  return generate_from_events(
340
  events,
341
  generate_tokens=generate_tokens,
342
  seed=seed,
 
 
 
343
  repo=DEFAULT_REPO,
344
  filename=DEFAULT_FILENAME,
345
  cache_dir=Path(".cache/godzilla"),
@@ -349,7 +890,6 @@ def generate_godzilla_continuation(
349
  device=device,
350
  )
351
 
352
-
353
  class GodzillaMidiModel(MidiModel):
354
  def __init__(self) -> None:
355
  super().__init__(model_id="godzilla", name="Godzilla")
@@ -360,12 +900,21 @@ class GodzillaMidiModel(MidiModel):
360
  *,
361
  tokens: int = 32,
362
  seed: Optional[int] = None,
 
 
 
 
 
363
  ) -> list[dict]:
364
  new_events, _ = generate_godzilla_continuation(
365
  events,
366
  generate_tokens=tokens,
367
  seed=seed,
368
- device="auto",
 
 
 
 
369
  )
370
  return new_events
371
 
 
3
 
4
  import subprocess
5
  import sys
6
+ import inspect
7
+ import re
8
+ import time
9
  from dataclasses import dataclass
10
  from pathlib import Path
11
+ from typing import Any, Iterable, Optional
12
 
13
  from config import MIDI_DEFAULTS, KEYBOARD_BASE_MIDI, KEYBOARD_OCTAVES
14
 
 
15
  DEFAULT_REPO = "asigalov61/Godzilla-Piano-Transformer"
16
  DEFAULT_FILENAME = (
17
+ "Godzilla_Piano_Transformer_No_Velocity_Trained_Model_21113_steps_"
18
+ "0.3454_loss_0.895_acc.pth"
19
  )
20
+ KNOWN_GODZILLA_CHECKPOINTS = [
21
+ "Godzilla_Piano_Transformer_No_Velocity_Trained_Model_21113_steps_0.3454_loss_0.895_acc.pth",
22
+ "Godzilla_Piano_Transformer_No_Velocity_Trained_Model_14903_steps_0.4874_loss_0.8571_acc.pth",
23
+ "Godzilla_Piano_Transformer_No_Velocity_Trained_Model_32503_steps_0.6553_loss_0.8065_acc.pth",
24
+ "Godzilla_Piano_Chords_Texturing_Transformer_Trained_Model_22708_steps_0.7515_loss_0.7853_acc.pth",
25
+ ]
26
 
27
  _MODEL_CACHE: dict[str, object] = {}
28
+ PROMPT_MAX_SECONDS = 18.0
29
+ PROMPT_PHRASE_GAP_SEC = 0.6
30
+ PROMPT_TARGET_CENTER_MIDI = 67
31
+ PROMPT_MAX_TRANSPOSE = 12
32
+ DEFAULT_MODEL_DIM = 2048
33
+ DEFAULT_MODEL_DEPTH = 8
34
+ DEFAULT_MODEL_HEADS = 32
35
+ DEFAULT_SEQ_LEN = 1536
36
+ DEFAULT_PAD_IDX = 708
37
 
38
 
39
  @dataclass(frozen=True)
 
47
  *,
48
  tokens: int = 32,
49
  seed: Optional[int] = None,
50
+ temperature: float = 0.9,
51
+ top_p: float = 0.95,
52
+ num_candidates: int = 1,
53
+ request: Any | None = None,
54
+ device: str = "auto",
55
  ) -> list[dict]:
56
  raise NotImplementedError
57
 
 
81
  return tools_dir, x_transformer_dir
82
 
83
 
84
+ def preload_godzilla_assets(
85
+ *,
86
+ repo: str = DEFAULT_REPO,
87
+ filename: str = DEFAULT_FILENAME,
88
+ cache_dir: Path = Path(".cache/godzilla"),
89
+ tegridy_dir: Path = Path("external"),
90
+ ) -> Path:
91
+ """
92
+ Download model checkpoint and Tegridy tools during app startup.
93
+ """
94
+ from huggingface_hub import hf_hub_download
95
+
96
+ cache_dir.mkdir(parents=True, exist_ok=True)
97
+ checkpoint_path = download_checkpoint_with_fallback(
98
+ repo=repo,
99
+ filename=filename,
100
+ cache_dir=cache_dir,
101
+ )
102
+ ensure_tegridy_tools(tegridy_dir)
103
+ return checkpoint_path
104
+
105
+
106
+ def preload_godzilla_model(
107
+ *,
108
+ repo: str = DEFAULT_REPO,
109
+ filename: str = DEFAULT_FILENAME,
110
+ cache_dir: Path = Path(".cache/godzilla"),
111
+ tegridy_dir: Path = Path("external"),
112
+ seq_len: int = DEFAULT_SEQ_LEN,
113
+ pad_idx: int = DEFAULT_PAD_IDX,
114
+ device: str = "cpu",
115
+ ) -> dict[str, Any]:
116
+ model, resolved_device, _, resolved_seq_len, resolved_pad_idx = load_model_cached(
117
+ repo=repo,
118
+ filename=filename,
119
+ cache_dir=cache_dir,
120
+ tegridy_dir=tegridy_dir,
121
+ seq_len=seq_len,
122
+ pad_idx=pad_idx,
123
+ device=device,
124
+ )
125
+ return {
126
+ "model_loaded": model is not None,
127
+ "device": resolved_device,
128
+ "seq_len": resolved_seq_len,
129
+ "pad_idx": resolved_pad_idx,
130
+ }
131
+
132
+
133
+ def candidate_checkpoint_filenames(primary: str) -> list[str]:
134
+ ordered = [primary]
135
+ for checkpoint in KNOWN_GODZILLA_CHECKPOINTS:
136
+ if checkpoint not in ordered:
137
+ ordered.append(checkpoint)
138
+ return ordered
139
+
140
+
141
+ def download_checkpoint_with_fallback(
142
+ *,
143
+ repo: str,
144
+ filename: str,
145
+ cache_dir: Path,
146
+ ) -> Path:
147
+ from huggingface_hub import hf_hub_download
148
+
149
+ cache_dir.mkdir(parents=True, exist_ok=True)
150
+ attempted: list[str] = []
151
+ last_error: Exception | None = None
152
+
153
+ for candidate in candidate_checkpoint_filenames(filename):
154
+ try:
155
+ return Path(
156
+ hf_hub_download(
157
+ repo_id=repo,
158
+ filename=candidate,
159
+ local_dir=str(cache_dir),
160
+ repo_type="model",
161
+ )
162
+ )
163
+ except Exception as exc:
164
+ attempted.append(candidate)
165
+ last_error = exc
166
+ message = str(exc)
167
+ if "Entry Not Found" in message or "404" in message:
168
+ continue
169
+ raise
170
+
171
+ raise RuntimeError(
172
+ f"Could not download any Godzilla checkpoint. Tried: {attempted}. "
173
+ f"Last error: {last_error}"
174
+ )
175
+
176
+
177
  def add_sys_path(*paths: Path) -> None:
178
  for path in paths:
179
  path_str = str(path.resolve())
 
181
  sys.path.insert(0, path_str)
182
 
183
 
184
+ def build_model(
185
+ seq_len: int,
186
+ pad_idx: int,
187
+ *,
188
+ dim: int = DEFAULT_MODEL_DIM,
189
+ depth: int = DEFAULT_MODEL_DEPTH,
190
+ heads: int = DEFAULT_MODEL_HEADS,
191
+ ):
192
  from x_transformer_2_3_1 import AutoregressiveWrapper, Decoder, TransformerWrapper
193
 
194
  model = TransformerWrapper(
195
  num_tokens=pad_idx + 1,
196
  max_seq_len=seq_len,
197
  attn_layers=Decoder(
198
+ dim=dim,
199
+ depth=depth,
200
+ heads=heads,
201
  rotary_pos_emb=True,
202
  attn_flash=True,
203
  ),
 
213
  return requested
214
 
215
 
216
+ def load_checkpoint_state(checkpoint_path: Path, device: str) -> dict[str, Any]:
217
  import torch
218
 
219
  state = torch.load(checkpoint_path, map_location=device)
220
+ if isinstance(state, dict) and "state_dict" in state and isinstance(state["state_dict"], dict):
221
+ state = state["state_dict"]
222
+ if not isinstance(state, dict):
223
+ raise RuntimeError(f"Unexpected checkpoint format at {checkpoint_path}")
224
+ return state
225
+
226
+
227
+ def infer_layer_entries_from_state(state: dict[str, Any]) -> int:
228
+ layer_ids: set[int] = set()
229
+ pattern = re.compile(r"^net\.attn_layers\.layers\.(\d+)\.")
230
+ for key in state.keys():
231
+ match = pattern.match(key)
232
+ if match:
233
+ layer_ids.add(int(match.group(1)))
234
+ if not layer_ids:
235
+ return DEFAULT_MODEL_DEPTH * 2
236
+ return max(layer_ids) + 1
237
+
238
+
239
+ def infer_model_shape_from_state(
240
+ state: dict[str, Any],
241
+ *,
242
+ fallback_seq_len: int,
243
+ fallback_pad_idx: int,
244
+ ) -> tuple[int, int, int, list[int]]:
245
+ emb = state.get("net.token_emb.emb.weight")
246
+ if emb is not None and hasattr(emb, "shape") and len(emb.shape) == 2:
247
+ num_tokens = int(emb.shape[0])
248
+ dim = int(emb.shape[1])
249
+ else:
250
+ num_tokens = fallback_pad_idx + 1
251
+ dim = DEFAULT_MODEL_DIM
252
+
253
+ pad_idx = max(0, num_tokens - 1)
254
+ seq_len = 4096 if num_tokens <= 385 else fallback_seq_len
255
+
256
+ layer_entries = infer_layer_entries_from_state(state)
257
+ depth_candidates: list[int] = []
258
+ for candidate in [max(1, layer_entries // 2), layer_entries]:
259
+ if candidate not in depth_candidates:
260
+ depth_candidates.append(candidate)
261
+
262
+ return seq_len, pad_idx, dim, depth_candidates
263
+
264
+
265
+ def adapt_state_for_model(
266
+ raw_state: dict[str, Any],
267
+ model_state: dict[str, Any],
268
+ ) -> dict[str, Any]:
269
+ adapted: dict[str, Any] = {}
270
+ model_keys = set(model_state.keys())
271
+
272
+ for key, value in raw_state.items():
273
+ if key in model_keys:
274
+ adapted[key] = value
275
+ continue
276
+ if key.endswith(".weight"):
277
+ gamma_key = key[:-7] + ".gamma"
278
+ if gamma_key in model_keys:
279
+ adapted[gamma_key] = value
280
+ continue
281
+ if key.endswith(".gamma"):
282
+ weight_key = key[:-6] + ".weight"
283
+ if weight_key in model_keys:
284
+ adapted[weight_key] = value
285
+ continue
286
+
287
+ # Fill optional affine params when norm conventions differ.
288
+ for key, tensor in model_state.items():
289
+ if key in adapted:
290
+ continue
291
+ if key.endswith(".bias"):
292
+ adapted[key] = tensor.new_zeros(tensor.shape)
293
+ elif key.endswith(".gamma"):
294
+ adapted[key] = tensor.new_ones(tensor.shape)
295
+
296
+ return adapted
297
+
298
+
299
+ def sanitize_events(events: list[dict]) -> list[dict]:
300
+ cleaned: list[dict] = []
301
+ for event in events:
302
+ if not isinstance(event, dict):
303
+ continue
304
+ ev_type = event.get("type")
305
+ if ev_type not in {"note_on", "note_off"}:
306
+ continue
307
+ note = max(0, min(127, int(event.get("note", 0))))
308
+ velocity = max(0, min(127, int(event.get("velocity", 0))))
309
+ time_sec = max(0.0, float(event.get("time", 0.0)))
310
+ cleaned.append(
311
+ {
312
+ "type": ev_type,
313
+ "note": note,
314
+ "velocity": velocity,
315
+ "time": time_sec,
316
+ "channel": int(event.get("channel", 0)),
317
+ }
318
+ )
319
+ cleaned.sort(key=lambda e: float(e.get("time", 0.0)))
320
+ return cleaned
321
+
322
+
323
+ def extract_prompt_window(
324
+ events: list[dict],
325
+ *,
326
+ max_seconds: float = PROMPT_MAX_SECONDS,
327
+ phrase_gap_sec: float = PROMPT_PHRASE_GAP_SEC,
328
+ ) -> list[dict]:
329
+ cleaned = sanitize_events(events)
330
+ if not cleaned:
331
+ return []
332
+
333
+ last_time = max(float(e.get("time", 0.0)) for e in cleaned)
334
+ recent_cut = max(0.0, last_time - max_seconds)
335
+
336
+ note_on_times = [
337
+ float(e.get("time", 0.0))
338
+ for e in cleaned
339
+ if e.get("type") == "note_on" and int(e.get("velocity", 0)) > 0
340
+ ]
341
+ if len(note_on_times) < 2:
342
+ return [e for e in cleaned if float(e.get("time", 0.0)) >= recent_cut]
343
+
344
+ phrase_starts = [note_on_times[0]]
345
+ for i in range(1, len(note_on_times)):
346
+ if note_on_times[i] - note_on_times[i - 1] >= phrase_gap_sec:
347
+ phrase_starts.append(note_on_times[i])
348
+
349
+ # Keep the last 1-2 phrases for coherent continuation.
350
+ phrase_cut = phrase_starts[-2] if len(phrase_starts) >= 2 else phrase_starts[-1]
351
+ cut = max(recent_cut, phrase_cut)
352
+ return [e for e in cleaned if float(e.get("time", 0.0)) >= cut]
353
+
354
+
355
+ def estimate_input_velocity(events: list[dict], default: int = 100) -> int:
356
+ velocities = [
357
+ int(e.get("velocity", 0))
358
+ for e in events
359
+ if e.get("type") == "note_on" and int(e.get("velocity", 0)) > 0
360
+ ]
361
+ if not velocities:
362
+ return default
363
+ avg = round(sum(velocities) / len(velocities))
364
+ return max(40, min(120, avg))
365
+
366
+
367
+ def compute_transpose_shift(
368
+ events: list[dict],
369
+ *,
370
+ target_center_midi: int = PROMPT_TARGET_CENTER_MIDI,
371
+ max_transpose: int = PROMPT_MAX_TRANSPOSE,
372
+ ) -> int:
373
+ pitches = [
374
+ int(e.get("note", 0))
375
+ for e in events
376
+ if e.get("type") == "note_on" and int(e.get("velocity", 0)) > 0
377
+ ]
378
+ if not pitches:
379
+ return 0
380
+ pitches.sort()
381
+ median_pitch = pitches[len(pitches) // 2]
382
+ shift = int(round(target_center_midi - median_pitch))
383
+ return max(-max_transpose, min(max_transpose, shift))
384
+
385
+
386
+ def transpose_events(events: list[dict], semitones: int) -> list[dict]:
387
+ if semitones == 0:
388
+ return [dict(event) for event in events]
389
+ out: list[dict] = []
390
+ for event in events:
391
+ copied = dict(event)
392
+ if copied.get("type") in {"note_on", "note_off"}:
393
+ copied["note"] = max(0, min(127, int(copied.get("note", 0)) + semitones))
394
+ out.append(copied)
395
+ return out
396
 
397
 
398
  def events_to_score_tokens(events: list[dict]) -> list[int]:
399
  if not events:
400
  return []
401
 
402
+ active: dict[int, list[tuple[float, int]]] = {}
403
  notes: list[tuple[float, float, int]] = []
404
  sorted_events = sorted(events, key=lambda e: e.get("time", 0.0))
405
 
 
410
  time_sec = float(event.get("time", 0.0))
411
 
412
  if ev_type == "note_on" and velocity > 0:
413
+ active.setdefault(note, []).append((time_sec, velocity))
414
  elif ev_type in {"note_off", "note_on"}:
415
+ if note in active and active[note]:
416
+ start, _ = active[note].pop(0)
417
+ duration = max(0.05, time_sec - start)
418
  notes.append((start, duration, note))
419
 
420
  if not notes:
 
509
  ]
510
 
511
 
512
+ def fold_note_to_keyboard_range(note: int) -> int:
513
+ min_note, max_note = keyboard_note_range()
514
+ folded = int(note)
515
+ while folded < min_note:
516
+ folded += 12
517
+ while folded > max_note:
518
+ folded -= 12
519
+ return max(min_note, min(max_note, folded))
520
+
521
+
522
+ def fold_events_to_keyboard_range(events: list[dict]) -> list[dict]:
523
+ out: list[dict] = []
524
+ for event in events:
525
+ copied = dict(event)
526
+ if copied.get("type") in {"note_on", "note_off"}:
527
+ copied["note"] = fold_note_to_keyboard_range(int(copied.get("note", 0)))
528
+ out.append(copied)
529
+ return out
530
+
531
+
532
+ def resolve_eos_token(pad_idx: int) -> int:
533
+ # Legacy Godzilla checkpoints use 707 as EOS with 708 pad.
534
+ if pad_idx >= 708:
535
+ return 707
536
+ return pad_idx
537
+
538
+
539
+ def build_prime_tokens(score_tokens: list[int], seq_len: int, pad_idx: int) -> list[int]:
540
+ num_tokens = pad_idx + 1
541
+ if pad_idx >= 708:
542
+ prime = [705, 384, 706]
543
+ if score_tokens:
544
+ max_score = max(0, seq_len - len(prime))
545
+ prime.extend(score_tokens[-max_score:])
546
+ else:
547
+ prime.extend([0, 129, 316])
548
  else:
549
+ if score_tokens:
550
+ prime = score_tokens[-max(1, seq_len) :]
551
+ else:
552
+ prime = [0, 129, 316]
553
+
554
+ # Keep prime tokens valid for current vocabulary.
555
+ return [max(0, min(num_tokens - 1, int(tok))) for tok in prime]
556
+
557
+
558
+ def build_generate_kwargs(
559
+ model,
560
+ temperature: float,
561
+ top_p: float,
562
+ eos_token: int,
563
+ ) -> dict[str, Any]:
564
+ kwargs: dict[str, Any] = {
565
+ "return_prime": True,
566
+ "eos_token": eos_token,
567
+ }
568
+ try:
569
+ signature = inspect.signature(model.generate)
570
+ except (TypeError, ValueError):
571
+ return kwargs
572
+
573
+ params = signature.parameters
574
+ safe_temperature = max(0.2, min(1.5, float(temperature)))
575
+ safe_top_p = max(0.5, min(0.99, float(top_p)))
576
+
577
+ if "temperature" in params:
578
+ kwargs["temperature"] = safe_temperature
579
+ if "top_p" in params:
580
+ kwargs["top_p"] = safe_top_p
581
+ elif "filter_thres" in params:
582
+ kwargs["filter_thres"] = safe_top_p
583
+ return kwargs
584
+
585
+
586
+ def generate_tokens_sample(
587
+ model,
588
+ prime_tensor,
589
+ generate_tokens: int,
590
+ *,
591
+ temperature: float,
592
+ top_p: float,
593
+ eos_token: int,
594
+ ) -> list[int]:
595
+ kwargs = build_generate_kwargs(model, temperature, top_p, eos_token)
596
+ try:
597
+ out = model.generate(
598
+ prime_tensor,
599
+ generate_tokens,
600
+ **kwargs,
601
+ )
602
+ except TypeError:
603
+ out = model.generate(
604
+ prime_tensor,
605
+ generate_tokens,
606
+ return_prime=True,
607
+ eos_token=eos_token,
608
+ )
609
+ return out.detach().cpu().tolist()
610
+
611
+
612
+ def score_candidate_events(events: list[dict], prompt_events: list[dict]) -> float:
613
+ notes = [
614
+ int(e.get("note", 0))
615
+ for e in events
616
+ if e.get("type") == "note_on" and int(e.get("velocity", 0)) > 0
617
+ ]
618
+ if not notes:
619
+ return -1e6
620
+
621
+ prompt_notes = [
622
+ int(e.get("note", 0))
623
+ for e in prompt_events
624
+ if e.get("type") == "note_on" and int(e.get("velocity", 0)) > 0
625
+ ]
626
+
627
+ min_note, max_note = keyboard_note_range()
628
+ out_of_range = sum(1 for note in notes if note < min_note or note > max_note)
629
+ repeats = sum(1 for i in range(1, len(notes)) if notes[i] == notes[i - 1])
630
+ big_leaps = sum(max(0, abs(notes[i] - notes[i - 1]) - 7) for i in range(1, len(notes)))
631
+
632
+ score = 0.0
633
+ score += min(len(notes), 24) * 0.25
634
+ score -= out_of_range * 3.5
635
+ score -= repeats * 0.2
636
+ score -= big_leaps * 0.08
637
+
638
+ if prompt_notes:
639
+ prompt_center = sum(prompt_notes) / len(prompt_notes)
640
+ notes_center = sum(notes) / len(notes)
641
+ score -= abs(notes_center - prompt_center) * 0.04
642
+ score -= abs(notes[0] - prompt_notes[-1]) * 0.03
643
+
644
+ return score
645
 
646
 
647
  def load_model_cached(
 
653
  seq_len: int,
654
  pad_idx: int,
655
  device: str,
656
+ ) -> tuple[object, str, Path, int, int]:
 
657
  import torch
658
 
659
  cache_dir.mkdir(parents=True, exist_ok=True)
660
  resolved_device = resolve_device(device)
 
661
 
662
+ checkpoint_path = download_checkpoint_with_fallback(
663
+ repo=repo,
664
+ filename=filename,
665
+ cache_dir=cache_dir,
 
 
 
 
 
 
 
 
 
 
666
  )
667
 
668
  tools_dir, x_transformer_dir = ensure_tegridy_tools(tegridy_dir)
 
673
  torch.backends.cuda.matmul.allow_tf32 = True
674
  torch.backends.cudnn.allow_tf32 = True
675
 
676
+ raw_state = load_checkpoint_state(checkpoint_path, "cpu")
677
+ inferred_seq_len, inferred_pad_idx, inferred_dim, depth_candidates = infer_model_shape_from_state(
678
+ raw_state,
679
+ fallback_seq_len=seq_len,
680
+ fallback_pad_idx=pad_idx,
681
+ )
682
+
683
+ cache_key = (
684
+ f"{repo}:{filename}:{resolved_device}:{inferred_seq_len}:{inferred_pad_idx}:"
685
+ f"{inferred_dim}:{'-'.join(str(d) for d in depth_candidates)}"
686
+ )
687
+ if _MODEL_CACHE.get("key") == cache_key:
688
+ return (
689
+ _MODEL_CACHE["model"],
690
+ _MODEL_CACHE["device"],
691
+ _MODEL_CACHE["tools_dir"],
692
+ _MODEL_CACHE["seq_len"],
693
+ _MODEL_CACHE["pad_idx"],
694
+ )
695
+
696
+ selected_model = None
697
+ selected_depth = None
698
+ last_error: RuntimeError | None = None
699
+ for depth in depth_candidates:
700
+ try:
701
+ model = build_model(
702
+ inferred_seq_len,
703
+ inferred_pad_idx,
704
+ dim=inferred_dim,
705
+ depth=depth,
706
+ heads=DEFAULT_MODEL_HEADS,
707
+ )
708
+ adapted_state = adapt_state_for_model(raw_state, model.state_dict())
709
+ missing, _ = model.load_state_dict(adapted_state, strict=False)
710
+ critical_missing = [
711
+ key
712
+ for key in missing
713
+ if not (key.endswith(".bias") or key.endswith(".gamma"))
714
+ ]
715
+ if critical_missing:
716
+ raise RuntimeError(
717
+ f"critical missing keys for depth={depth}: {critical_missing[:5]}"
718
+ )
719
+ selected_model = model
720
+ selected_depth = depth
721
+ break
722
+ except RuntimeError as exc:
723
+ last_error = exc
724
+
725
+ if selected_model is None:
726
+ raise RuntimeError(
727
+ f"Could not load checkpoint {filename} with inferred configs. "
728
+ f"Tried depths={depth_candidates}. Last error: {last_error}"
729
+ )
730
+
731
+ model = selected_model
732
  model.to(resolved_device)
733
  model.eval()
734
+ if resolved_device == "cuda":
735
+ first_param = next(model.parameters(), None)
736
+ if first_param is None or not first_param.is_cuda:
737
+ raise RuntimeError("Godzilla model failed to move to CUDA device")
738
+
739
+ print(
740
+ "Loaded Godzilla checkpoint config:",
741
+ {
742
+ "filename": filename,
743
+ "seq_len": inferred_seq_len,
744
+ "pad_idx": inferred_pad_idx,
745
+ "dim": inferred_dim,
746
+ "depth": selected_depth,
747
+ "device": resolved_device,
748
+ "cuda_available": torch.cuda.is_available(),
749
+ },
750
+ )
751
 
752
  _MODEL_CACHE["key"] = cache_key
753
  _MODEL_CACHE["model"] = model
754
  _MODEL_CACHE["device"] = resolved_device
755
  _MODEL_CACHE["tools_dir"] = tools_dir
756
  _MODEL_CACHE["checkpoint_path"] = checkpoint_path
757
+ _MODEL_CACHE["seq_len"] = inferred_seq_len
758
+ _MODEL_CACHE["pad_idx"] = inferred_pad_idx
759
 
760
+ return model, resolved_device, tools_dir, inferred_seq_len, inferred_pad_idx
761
 
762
 
763
  def generate_from_events(
 
765
  *,
766
  generate_tokens: int,
767
  seed: int | None,
768
+ temperature: float,
769
+ top_p: float,
770
+ num_candidates: int,
771
  repo: str,
772
  filename: str,
773
  cache_dir: Path,
 
778
  ) -> tuple[list[dict], list[int]]:
779
  import torch
780
 
781
+ model, resolved_device, _, resolved_seq_len, resolved_pad_idx = load_model_cached(
782
  repo=repo,
783
  filename=filename,
784
  cache_dir=cache_dir,
 
788
  device=device,
789
  )
790
 
791
+ prompt_events = extract_prompt_window(events)
792
+ transpose_shift = compute_transpose_shift(prompt_events)
793
+ transposed_prompt_events = transpose_events(prompt_events, transpose_shift)
794
+ score_tokens = events_to_score_tokens(transposed_prompt_events)
795
+ prime = build_prime_tokens(score_tokens, resolved_seq_len, resolved_pad_idx)
 
 
796
  prime_tensor = torch.tensor(prime, dtype=torch.long, device=resolved_device)
797
+ eos_token = resolve_eos_token(resolved_pad_idx)
 
 
 
 
 
 
 
 
 
798
 
799
  last_time_ms = 0.0
800
  if events:
801
  last_time_ms = max(float(e.get("time", 0.0)) for e in events) * 1000.0
802
 
803
+ input_velocity = estimate_input_velocity(prompt_events)
804
+ best_events: list[dict] = []
805
+ best_tokens: list[int] = []
806
+ best_score = -1e9
807
+
808
+ candidate_count = max(1, min(6, int(num_candidates)))
809
+ generation_started = time.perf_counter()
810
+ candidate_timings_ms: list[float] = []
811
+ for idx in range(candidate_count):
812
+ if seed is not None:
813
+ sample_seed = int(seed) + idx
814
+ torch.manual_seed(sample_seed)
815
+ if resolved_device == "cuda":
816
+ torch.cuda.manual_seed_all(sample_seed)
817
+
818
+ candidate_started = time.perf_counter()
819
+ tokens = generate_tokens_sample(
820
+ model,
821
+ prime_tensor,
822
+ generate_tokens,
823
+ temperature=temperature,
824
+ top_p=top_p,
825
+ eos_token=eos_token,
826
+ )
827
+ if resolved_device == "cuda":
828
+ torch.cuda.synchronize()
829
+ candidate_timings_ms.append((time.perf_counter() - candidate_started) * 1000.0)
830
+ new_tokens = tokens[len(prime) :]
831
+
832
+ candidate_events = tokens_to_events(
833
+ new_tokens,
834
+ offset_ms=last_time_ms,
835
+ velocity=input_velocity,
836
+ )
837
+ candidate_events = transpose_events(candidate_events, -transpose_shift)
838
+ candidate_score = score_candidate_events(candidate_events, prompt_events)
839
+
840
+ if candidate_score > best_score or idx == 0:
841
+ best_score = candidate_score
842
+ best_events = candidate_events
843
+ best_tokens = new_tokens
844
+
845
+ generation_total_ms = (time.perf_counter() - generation_started) * 1000.0
846
+ avg_candidate_ms = (
847
+ sum(candidate_timings_ms) / len(candidate_timings_ms)
848
+ if candidate_timings_ms
849
+ else 0.0
850
+ )
851
+ print(
852
+ "Godzilla generation timing (model load excluded):",
853
+ {
854
+ "device": resolved_device,
855
+ "generate_tokens": generate_tokens,
856
+ "candidates": candidate_count,
857
+ "candidate_ms": [round(ms, 2) for ms in candidate_timings_ms],
858
+ "avg_candidate_ms": round(avg_candidate_ms, 2),
859
+ "total_generation_ms": round(generation_total_ms, 2),
860
+ },
861
+ )
862
+
863
+ return best_events, best_tokens
864
 
865
 
866
  def generate_godzilla_continuation(
 
868
  *,
869
  generate_tokens: int = 32,
870
  seed: int | None = None,
871
+ temperature: float = 0.9,
872
+ top_p: float = 0.95,
873
+ num_candidates: int = 1,
874
  device: str = "auto",
875
+ request: Any | None = None,
876
  ) -> tuple[list[dict], list[int]]:
877
  return generate_from_events(
878
  events,
879
  generate_tokens=generate_tokens,
880
  seed=seed,
881
+ temperature=temperature,
882
+ top_p=top_p,
883
+ num_candidates=num_candidates,
884
  repo=DEFAULT_REPO,
885
  filename=DEFAULT_FILENAME,
886
  cache_dir=Path(".cache/godzilla"),
 
890
  device=device,
891
  )
892
 
 
893
  class GodzillaMidiModel(MidiModel):
894
  def __init__(self) -> None:
895
  super().__init__(model_id="godzilla", name="Godzilla")
 
900
  *,
901
  tokens: int = 32,
902
  seed: Optional[int] = None,
903
+ temperature: float = 0.9,
904
+ top_p: float = 0.95,
905
+ num_candidates: int = 1,
906
+ request: Any | None = None,
907
+ device: str = "auto",
908
  ) -> list[dict]:
909
  new_events, _ = generate_godzilla_continuation(
910
  events,
911
  generate_tokens=tokens,
912
  seed=seed,
913
+ temperature=temperature,
914
+ top_p=top_p,
915
+ num_candidates=num_candidates,
916
+ device=device,
917
+ request=request,
918
  )
919
  return new_events
920
 
pyproject.toml CHANGED
@@ -5,6 +5,6 @@ description = "Add your description here"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
- "gradio>=6.5.1",
9
  "mido>=1.3.3",
10
  ]
 
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
+ "gradio==5.49.1",
9
  "mido>=1.3.3",
10
  ]
requirements.txt CHANGED
@@ -3,4 +3,5 @@ mido
3
  torch
4
  huggingface_hub
5
  einops>=0.6
6
- einx
 
 
3
  torch
4
  huggingface_hub
5
  einops>=0.6
6
+ einx
7
+ spaces
static/keyboard.js CHANGED
@@ -65,30 +65,105 @@ const keyShortcuts = {
65
  // DOM ELEMENTS
66
  // =============================================================================
67
 
68
- const keyboardEl = document.getElementById('keyboard');
69
- const statusEl = document.getElementById('status');
70
- const recordBtn = document.getElementById('recordBtn');
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');
78
- const terminal = document.getElementById('terminal');
79
- const clearTerminal = document.getElementById('clearTerminal');
 
 
 
 
 
 
 
80
 
81
  // =============================================================================
82
  // STATE
83
  // =============================================================================
84
 
85
  let synth = null;
 
86
  let recording = false;
87
  let startTime = 0;
88
  let events = [];
89
  const pressedKeys = new Set();
90
  let selectedEngine = 'parrot'; // Default engine
91
  let serverConfig = null; // Will hold instruments and keyboard config from server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  // =============================================================================
94
  // INSTRUMENT FACTORY
@@ -128,16 +203,28 @@ let instruments = {}; // Will be populated after config is fetched
128
  function populateEngineSelect(engines) {
129
  if (!engineSelect || !Array.isArray(engines)) return;
130
 
 
 
 
 
 
 
 
131
  engineSelect.innerHTML = '';
132
  engines.forEach(engine => {
133
  const option = document.createElement('option');
134
  option.value = engine.id;
135
  option.textContent = engine.name || engine.id;
 
 
 
 
136
  engineSelect.appendChild(option);
137
  });
138
 
139
  if (engines.length > 0) {
140
- selectedEngine = engines[0].id;
 
141
  engineSelect.value = selectedEngine;
142
  }
143
  }
@@ -151,10 +238,10 @@ async function initializeFromConfig() {
151
  * Fetch configuration from Python server and initialize UI
152
  */
153
  try {
154
- const response = await fetch('/gradio_api/api/config');
155
- if (!response.ok) throw new Error(`Config fetch failed: ${response.status}`);
156
-
157
- serverConfig = await response.json();
158
 
159
  // Build instruments from config
160
  instruments = buildInstruments(serverConfig.instruments);
@@ -203,6 +290,14 @@ function loadInstrument(type) {
203
  synth = instruments[type]();
204
  }
205
 
 
 
 
 
 
 
 
 
206
  // =============================================================================
207
  // KEYBOARD RENDERING
208
  // =============================================================================
@@ -247,6 +342,557 @@ function nowSec() {
247
  return performance.now() / 1000;
248
  }
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  // =============================================================================
251
  // TERMINAL LOGGING
252
  // =============================================================================
@@ -362,37 +1008,39 @@ function getKeyElement(midiNote) {
362
  return keyboardEl.querySelector(`.key[data-midi="${midiNote}"]`);
363
  }
364
 
365
- document.addEventListener('keydown', async (ev) => {
366
- if (!keyboardToggle.checked) return;
367
- const key = ev.key.toLowerCase();
368
- const keyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
369
- if (!keyMap[key] || pressedKeys.has(key)) return;
370
-
371
- ev.preventDefault();
372
- pressedKeys.add(key);
373
-
374
- await Tone.start();
375
-
376
- const midiNote = keyMap[key];
377
- const keyEl = getKeyElement(midiNote);
378
- if (keyEl) keyEl.style.filter = 'brightness(0.85)';
379
- noteOn(midiNote, 100);
380
- });
381
-
382
- document.addEventListener('keyup', (ev) => {
383
- if (!keyboardToggle.checked) return;
384
- const key = ev.key.toLowerCase();
385
- const keyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
386
- if (!keyMap[key] || !pressedKeys.has(key)) return;
387
-
388
- ev.preventDefault();
389
- pressedKeys.delete(key);
390
-
391
- const midiNote = keyMap[key];
392
- const keyEl = getKeyElement(midiNote);
393
- if (keyEl) keyEl.style.filter = '';
394
- noteOff(midiNote);
395
- });
 
 
396
 
397
  // =============================================================================
398
  // MOUSE/TOUCH INPUT
@@ -451,36 +1099,7 @@ async function saveMIDI() {
451
  saveBtn.disabled = true;
452
 
453
  try {
454
- const startResp = await fetch('/gradio_api/call/save_midi', {
455
- method: 'POST',
456
- headers: {'Content-Type':'application/json'},
457
- body: JSON.stringify({data: [events]})
458
- });
459
-
460
- if (!startResp.ok) {
461
- const txt = await startResp.text();
462
- throw new Error('Server error: ' + txt);
463
- }
464
-
465
- const startJson = await startResp.json();
466
- if (!startJson || !startJson.event_id) {
467
- throw new Error('Invalid API response');
468
- }
469
-
470
- const resultResp = await fetch(`/gradio_api/call/save_midi/${startJson.event_id}`);
471
- if (!resultResp.ok) {
472
- const txt = await resultResp.text();
473
- throw new Error('Server error: ' + txt);
474
- }
475
-
476
- const resultText = await resultResp.text();
477
- const dataLine = resultText.split('\n').find(line => line.startsWith('data:'));
478
- if (!dataLine) {
479
- throw new Error('Invalid API response');
480
- }
481
-
482
- const payloadList = JSON.parse(dataLine.replace('data:', '').trim());
483
- const payload = Array.isArray(payloadList) ? payloadList[0] : null;
484
  if (!payload || payload.error || !payload.midi_base64) {
485
  throw new Error(payload && payload.error ? payload.error : 'Invalid API response');
486
  }
@@ -507,214 +1126,568 @@ async function saveMIDI() {
507
  }
508
  }
509
 
510
- // =============================================================================
511
- // EVENT LISTENERS
512
- // =============================================================================
 
 
 
 
 
 
 
513
 
514
- instrumentSelect.addEventListener('change', () => {
515
- loadInstrument(instrumentSelect.value);
516
- });
 
 
517
 
518
- keyboardToggle.addEventListener('change', () => {
519
- if (keyboardToggle.checked) {
520
- // Show keyboard shortcuts
521
- keyboardEl.classList.add('shortcuts-visible');
522
- } else {
523
- // Hide keyboard shortcuts
524
- keyboardEl.classList.remove('shortcuts-visible');
525
- // Release all currently pressed keyboard keys
526
- pressedKeys.forEach(key => {
527
- const midiNote = keyMap[key];
528
- const keyEl = getKeyElement(midiNote);
529
- if (keyEl) keyEl.style.filter = '';
530
- noteOff(midiNote);
531
- });
532
- pressedKeys.clear();
533
  }
534
- });
535
 
536
- clearTerminal.addEventListener('click', () => {
537
- terminal.innerHTML = '';
538
- logToTerminal('╔═══════════════════════════════════════════════════════╗', 'timestamp');
539
- logToTerminal('║ 🎹 MIDI MONITOR INITIALIZED 🎹 ║', 'timestamp');
540
- logToTerminal('╚═══════════════════════════════════════════════════════╝', 'timestamp');
541
- logToTerminal('Ready to capture MIDI events...', 'timestamp');
542
- logToTerminal('', '');
543
- });
544
 
545
- recordBtn.addEventListener('click', async () => {
546
- await Tone.start();
547
- beginRecord();
548
- });
 
549
 
550
- stopBtn.addEventListener('click', () => stopRecord());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
- engineSelect.addEventListener('change', (e) => {
553
- selectedEngine = e.target.value;
554
- logToTerminal(`Engine switched to: ${selectedEngine}`, 'timestamp');
555
- });
 
 
 
 
 
 
556
 
557
- playbackBtn.addEventListener('click', async () => {
558
- if (events.length === 0) return alert('No recording to play back');
559
-
560
- // Ensure all notes are off before starting playback
561
- if (synth) {
562
- synth.releaseAll();
 
 
563
  }
564
- keyboardEl.querySelectorAll('.key').forEach(k => {
565
- k.style.filter = '';
566
- });
567
-
568
- statusEl.textContent = 'Playing back...';
569
- playbackBtn.disabled = true;
570
- recordBtn.disabled = true;
571
-
572
- logToTerminal('', '');
573
- logToTerminal('♫♫♫ PLAYBACK STARTED ♫♫♫', 'timestamp');
574
- logToTerminal('', '');
575
-
576
- try {
577
- // Process events through the selected engine
578
- let processedEvents = events;
579
- const selectedEngine = engineSelect.value;
580
-
581
- if (selectedEngine && selectedEngine !== 'parrot') {
582
- // Step 1: Start the engine processing call
583
- const startResp = await fetch('/gradio_api/call/process_engine', {
584
- method: 'POST',
585
- headers: { 'Content-Type': 'application/json' },
586
- body: JSON.stringify({
587
- data: [{
588
- engine_id: selectedEngine,
589
- events: events
590
- }]
591
- })
592
- });
593
-
594
- if (!startResp.ok) {
595
- console.error('Engine API start failed:', startResp.status);
596
- } else {
597
- const startJson = await startResp.json();
598
 
599
-
600
- // Step 2: Poll for the result
601
- if (startJson && startJson.event_id) {
602
- const resultResp = await fetch(`/gradio_api/call/process_engine/${startJson.event_id}`);
603
- if (resultResp.ok) {
604
- const resultText = await resultResp.text();
605
- const dataLine = resultText.split('\n').find(line => line.startsWith('data:'));
606
- if (dataLine) {
607
- const payloadList = JSON.parse(dataLine.replace('data:', '').trim());
608
- const result = Array.isArray(payloadList) ? payloadList[0] : null;
609
-
610
- if (result && result.events) {
611
- processedEvents = result.events;
612
- }
613
- }
614
- }
615
- }
616
- }
617
  }
618
-
619
- // Play back the recorded events
620
- statusEl.textContent = 'Playing back...';
621
  let eventIndex = 0;
622
-
623
  const playEvent = () => {
624
- if (eventIndex >= processedEvents.length) {
625
- // Playback complete - ensure all notes are off
626
- if (synth) {
627
- synth.releaseAll();
628
- }
629
-
630
- // Clear all key highlights
631
  keyboardEl.querySelectorAll('.key').forEach(k => {
632
  k.style.filter = '';
633
  });
634
-
635
- statusEl.textContent = 'Playback complete';
636
- playbackBtn.disabled = false;
637
- recordBtn.disabled = false;
638
-
639
- logToTerminal('', '');
640
- logToTerminal('♫♫♫ PLAYBACK FINISHED ♫♫♫', 'timestamp');
641
- logToTerminal('', '');
642
  return;
643
  }
644
-
645
- const event = processedEvents[eventIndex];
646
- const nextTime = eventIndex + 1 < processedEvents.length
647
- ? processedEvents[eventIndex + 1].time
648
  : event.time;
649
-
650
  if (event.type === 'note_on') {
651
  const freq = Tone.Frequency(event.note, "midi").toFrequency();
652
- synth.triggerAttack(freq, undefined, event.velocity / 127);
653
-
654
- const noteName = midiToNoteName(event.note);
655
- logToTerminal(
656
- `[${event.time.toFixed(3)}s] ► ${noteName} (${event.note})`,
657
- 'note-on'
658
- );
659
-
660
- // Highlight the key being played
 
661
  const keyEl = getKeyElement(event.note);
662
  if (keyEl) keyEl.style.filter = 'brightness(0.7)';
663
  } else if (event.type === 'note_off') {
664
  const freq = Tone.Frequency(event.note, "midi").toFrequency();
665
- synth.triggerRelease(freq);
666
-
667
- const noteName = midiToNoteName(event.note);
668
- logToTerminal(
669
- `[${event.time.toFixed(3)}s] ◄ ${noteName}`,
670
- 'note-off'
671
- );
672
-
673
- // Remove key highlight
 
674
  const keyEl = getKeyElement(event.note);
675
  if (keyEl) keyEl.style.filter = '';
676
  }
677
-
678
  eventIndex++;
679
  const deltaTime = Math.max(0, nextTime - event.time);
680
  setTimeout(playEvent, deltaTime * 1000);
681
  };
682
-
683
  playEvent();
684
- } catch (err) {
685
- console.error('Playback error:', err);
686
- statusEl.textContent = 'Playback error: ' + err.message;
687
- playbackBtn.disabled = false;
688
- recordBtn.disabled = false;
689
-
690
- // Ensure all notes are off on error
691
- if (synth) {
692
- synth.releaseAll();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  }
694
- keyboardEl.querySelectorAll('.key').forEach(k => {
695
- k.style.filter = '';
696
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  }
698
- });
699
 
700
- saveBtn.addEventListener('click', () => saveMIDI());
 
 
701
 
702
- panicBtn.addEventListener('click', () => {
703
- // Stop all notes immediately
704
- if (synth) {
705
- synth.releaseAll();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  }
707
-
708
- // Clear all pressed keys
709
- pressedKeys.clear();
710
-
711
- // Reset all visual key highlights
 
 
 
 
 
712
  keyboardEl.querySelectorAll('.key').forEach(k => {
713
  k.style.filter = '';
714
  });
715
-
716
- logToTerminal('🚨 PANIC - All notes stopped', 'timestamp');
717
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
 
719
  // =============================================================================
720
  // =============================================================================
@@ -722,22 +1695,54 @@ panicBtn.addEventListener('click', () => {
722
  // =============================================================================
723
 
724
  async function init() {
 
 
 
 
 
725
  // First, load configuration from server
726
  await initializeFromConfig();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
 
728
  // Then load default instrument (synth)
729
  loadInstrument('synth');
 
730
 
731
  // Setup keyboard event listeners and UI
732
  attachPointerEvents();
733
  initTerminal();
734
-
 
 
735
  // Set initial button states
736
  recordBtn.disabled = false;
737
  stopBtn.disabled = true;
738
  saveBtn.disabled = true;
739
  playbackBtn.disabled = true;
 
 
740
  }
741
 
742
  // Start the application when DOM is ready
743
- document.addEventListener('DOMContentLoaded', init);
 
 
 
 
 
 
 
65
  // DOM ELEMENTS
66
  // =============================================================================
67
 
68
+ let keyboardEl = null;
69
+ let statusEl = null;
70
+ let recordBtn = null;
71
+ let stopBtn = null;
72
+ let playbackBtn = null;
73
+ let gameStartBtn = null;
74
+ let gameStopBtn = null;
75
+ let saveBtn = null;
76
+ let panicBtn = null;
77
+ let keyboardToggle = null;
78
+ let instrumentSelect = null;
79
+ let aiInstrumentSelect = null;
80
+ let engineSelect = null;
81
+ let runtimeSelect = null;
82
+ let responseStyleSelect = null;
83
+ let responseModeSelect = null;
84
+ let responseLengthSelect = null;
85
+ let terminal = null;
86
+ let clearTerminal = null;
87
 
88
  // =============================================================================
89
  // STATE
90
  // =============================================================================
91
 
92
  let synth = null;
93
+ let aiSynth = null;
94
  let recording = false;
95
  let startTime = 0;
96
  let events = [];
97
  const pressedKeys = new Set();
98
  let selectedEngine = 'parrot'; // Default engine
99
  let serverConfig = null; // Will hold instruments and keyboard config from server
100
+ let gameActive = false;
101
+ let gameTurn = 0;
102
+ let gameTurnTimerId = null;
103
+ let gameTurnTimeoutId = null;
104
+
105
+ const USER_TURN_LIMIT_SEC = 6;
106
+ const GAME_NEXT_TURN_DELAY_MS = 800;
107
+
108
+ const RESPONSE_MODES = {
109
+ raw_godzilla: { label: 'Raw Godzilla' },
110
+ current_pipeline: { label: 'Current Pipeline' },
111
+ musical_polish: { label: 'Musical Polish' }
112
+ };
113
+
114
+ const RESPONSE_LENGTH_PRESETS = {
115
+ short: {
116
+ label: 'Short',
117
+ generateTokens: 32,
118
+ maxNotes: 8,
119
+ maxDurationSec: 4.0
120
+ },
121
+ medium: {
122
+ label: 'Medium',
123
+ generateTokens: 64,
124
+ maxNotes: 14,
125
+ maxDurationSec: 6.0
126
+ },
127
+ long: {
128
+ label: 'Long',
129
+ generateTokens: 96,
130
+ maxNotes: 20,
131
+ maxDurationSec: 8.0
132
+ },
133
+ extended: {
134
+ label: 'Extended',
135
+ generateTokens: 128,
136
+ maxNotes: 28,
137
+ maxDurationSec: 11.0
138
+ }
139
+ };
140
+
141
+ const RESPONSE_STYLE_PRESETS = {
142
+ melodic: {
143
+ label: 'Melodic',
144
+ maxNotes: 8,
145
+ maxDurationSec: 4.0,
146
+ smoothLeaps: true,
147
+ addMotifEcho: false,
148
+ playfulShift: false
149
+ },
150
+ motif_echo: {
151
+ label: 'Motif Echo',
152
+ maxNotes: 10,
153
+ maxDurationSec: 4.3,
154
+ smoothLeaps: true,
155
+ addMotifEcho: true,
156
+ playfulShift: false
157
+ },
158
+ playful: {
159
+ label: 'Playful',
160
+ maxNotes: 9,
161
+ maxDurationSec: 3.8,
162
+ smoothLeaps: true,
163
+ addMotifEcho: false,
164
+ playfulShift: true
165
+ }
166
+ };
167
 
168
  // =============================================================================
169
  // INSTRUMENT FACTORY
 
203
  function populateEngineSelect(engines) {
204
  if (!engineSelect || !Array.isArray(engines)) return;
205
 
206
+ // Tooltip map for engine options
207
+ const engineTooltips = {
208
+ 'parrot': 'Repeats your exact melody',
209
+ 'reverse_parrot': 'Plays your melody backward',
210
+ 'godzilla_continue': 'MIDI transformer'
211
+ };
212
+
213
  engineSelect.innerHTML = '';
214
  engines.forEach(engine => {
215
  const option = document.createElement('option');
216
  option.value = engine.id;
217
  option.textContent = engine.name || engine.id;
218
+ // Add tooltip attribute for hover display
219
+ if (engineTooltips[engine.id]) {
220
+ option.setAttribute('data-tooltip', engineTooltips[engine.id]);
221
+ }
222
  engineSelect.appendChild(option);
223
  });
224
 
225
  if (engines.length > 0) {
226
+ const hasGodzilla = engines.some(engine => engine.id === 'godzilla_continue');
227
+ selectedEngine = hasGodzilla ? 'godzilla_continue' : engines[0].id;
228
  engineSelect.value = selectedEngine;
229
  }
230
  }
 
238
  * Fetch configuration from Python server and initialize UI
239
  */
240
  try {
241
+ serverConfig = await callGradioBridge('config', {});
242
+ if (!serverConfig || typeof serverConfig !== 'object') {
243
+ throw new Error('Invalid config payload');
244
+ }
245
 
246
  // Build instruments from config
247
  instruments = buildInstruments(serverConfig.instruments);
 
290
  synth = instruments[type]();
291
  }
292
 
293
+ function loadAIInstrument(type) {
294
+ if (aiSynth) {
295
+ aiSynth.releaseAll();
296
+ aiSynth.dispose();
297
+ }
298
+ aiSynth = instruments[type]();
299
+ }
300
+
301
  // =============================================================================
302
  // KEYBOARD RENDERING
303
  // =============================================================================
 
342
  return performance.now() / 1000;
343
  }
344
 
345
+ function getBridgeButton(buttonId) {
346
+ return document.getElementById(buttonId) || document.querySelector(`#${buttonId} button`);
347
+ }
348
+
349
+ function getBridgeField(fieldId) {
350
+ const root = document.getElementById(fieldId);
351
+ if (!root) return null;
352
+ if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) {
353
+ return root;
354
+ }
355
+ return root.querySelector('textarea, input');
356
+ }
357
+
358
+ function setFieldValue(field, value) {
359
+ const setter = field instanceof HTMLTextAreaElement
360
+ ? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
361
+ : Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
362
+ if (setter) {
363
+ setter.call(field, value);
364
+ } else {
365
+ field.value = value;
366
+ }
367
+ field.dispatchEvent(new Event('input', { bubbles: true }));
368
+ field.dispatchEvent(new Event('change', { bubbles: true }));
369
+ }
370
+
371
+ function waitForFieldUpdate(field, previousValue, timeoutMs = 120000) {
372
+ return new Promise((resolve, reject) => {
373
+ const deadline = Date.now() + timeoutMs;
374
+
375
+ const check = () => {
376
+ const nextValue = field.value || '';
377
+ if (nextValue !== previousValue && nextValue !== '') {
378
+ resolve(nextValue);
379
+ return;
380
+ }
381
+ if (Date.now() > deadline) {
382
+ reject(new Error('Timed out waiting for Gradio response'));
383
+ return;
384
+ }
385
+ setTimeout(check, 80);
386
+ };
387
+
388
+ check();
389
+ });
390
+ }
391
+
392
+ async function waitForBridgeElements(timeoutMs = 20000) {
393
+ const required = [
394
+ { kind: 'field', id: 'vk_config_input' },
395
+ { kind: 'field', id: 'vk_config_output' },
396
+ { kind: 'button', id: 'vk_config_btn' },
397
+ { kind: 'field', id: 'vk_save_input' },
398
+ { kind: 'field', id: 'vk_save_output' },
399
+ { kind: 'button', id: 'vk_save_btn' },
400
+ { kind: 'field', id: 'vk_engine_input' },
401
+ { kind: 'field', id: 'vk_engine_cpu_output' },
402
+ { kind: 'button', id: 'vk_engine_cpu_btn' },
403
+ { kind: 'field', id: 'vk_engine_gpu_output' },
404
+ { kind: 'button', id: 'vk_engine_gpu_btn' }
405
+ ];
406
+
407
+ const started = Date.now();
408
+ while (Date.now() - started < timeoutMs) {
409
+ const allReady = required.every(item => (
410
+ item.kind === 'button'
411
+ ? Boolean(getBridgeButton(item.id))
412
+ : Boolean(getBridgeField(item.id))
413
+ ));
414
+ if (allReady) return;
415
+ await new Promise(resolve => setTimeout(resolve, 100));
416
+ }
417
+ throw new Error('Gradio bridge elements were not ready in time');
418
+ }
419
+
420
+ function cacheUIElements() {
421
+ keyboardEl = document.getElementById('keyboard');
422
+ statusEl = document.getElementById('status');
423
+ recordBtn = document.getElementById('recordBtn');
424
+ stopBtn = document.getElementById('stopBtn');
425
+ playbackBtn = document.getElementById('playbackBtn');
426
+ gameStartBtn = document.getElementById('gameStartBtn');
427
+ gameStopBtn = document.getElementById('gameStopBtn');
428
+ saveBtn = document.getElementById('saveBtn');
429
+ panicBtn = document.getElementById('panicBtn');
430
+ keyboardToggle = document.getElementById('keyboardToggle');
431
+ instrumentSelect = document.getElementById('instrumentSelect');
432
+ aiInstrumentSelect = document.getElementById('aiInstrumentSelect');
433
+ engineSelect = document.getElementById('engineSelect');
434
+ runtimeSelect = document.getElementById('runtimeSelect');
435
+ responseStyleSelect = document.getElementById('responseStyleSelect');
436
+ responseModeSelect = document.getElementById('responseModeSelect');
437
+ responseLengthSelect = document.getElementById('responseLengthSelect');
438
+ terminal = document.getElementById('terminal');
439
+ clearTerminal = document.getElementById('clearTerminal');
440
+ }
441
+
442
+ async function waitForKeyboardUIElements(timeoutMs = 20000) {
443
+ const requiredIds = [
444
+ 'keyboard',
445
+ 'status',
446
+ 'recordBtn',
447
+ 'stopBtn',
448
+ 'playbackBtn',
449
+ 'gameStartBtn',
450
+ 'gameStopBtn',
451
+ 'saveBtn',
452
+ 'panicBtn',
453
+ 'keyboardToggle',
454
+ 'instrumentSelect',
455
+ 'engineSelect',
456
+ 'runtimeSelect',
457
+ 'terminal',
458
+ 'clearTerminal'
459
+ ];
460
+
461
+ const started = Date.now();
462
+ while (Date.now() - started < timeoutMs) {
463
+ const allReady = requiredIds.every(id => Boolean(document.getElementById(id)));
464
+ if (allReady) return;
465
+ await new Promise(resolve => setTimeout(resolve, 100));
466
+ }
467
+ throw new Error('Keyboard UI elements were not ready in time');
468
+ }
469
+
470
+ const BRIDGE_ACTIONS = {
471
+ config: {
472
+ inputId: 'vk_config_input',
473
+ outputId: 'vk_config_output',
474
+ buttonId: 'vk_config_btn'
475
+ },
476
+ save_midi: {
477
+ inputId: 'vk_save_input',
478
+ outputId: 'vk_save_output',
479
+ buttonId: 'vk_save_btn'
480
+ },
481
+ process_engine_cpu: {
482
+ inputId: 'vk_engine_input',
483
+ outputId: 'vk_engine_cpu_output',
484
+ buttonId: 'vk_engine_cpu_btn'
485
+ },
486
+ process_engine_gpu: {
487
+ inputId: 'vk_engine_input',
488
+ outputId: 'vk_engine_gpu_output',
489
+ buttonId: 'vk_engine_gpu_btn'
490
+ }
491
+ };
492
+
493
+ async function callGradioBridge(action, payload) {
494
+ const bridge = BRIDGE_ACTIONS[action];
495
+ if (!bridge) {
496
+ throw new Error(`Unknown bridge action: ${action}`);
497
+ }
498
+
499
+ const inputField = getBridgeField(bridge.inputId);
500
+ const outputField = getBridgeField(bridge.outputId);
501
+ const button = getBridgeButton(bridge.buttonId);
502
+ if (!inputField || !outputField || !button) {
503
+ throw new Error(`Bridge controls missing for action: ${action}`);
504
+ }
505
+
506
+ const requestPayload = payload === undefined ? {} : payload;
507
+ setFieldValue(inputField, JSON.stringify(requestPayload));
508
+
509
+ const previousOutput = outputField.value || '';
510
+ setFieldValue(outputField, '');
511
+ button.click();
512
+
513
+ const outputText = await waitForFieldUpdate(outputField, previousOutput);
514
+ try {
515
+ return JSON.parse(outputText);
516
+ } catch (err) {
517
+ throw new Error(`Invalid bridge JSON for ${action}: ${outputText}`);
518
+ }
519
+ }
520
+
521
+ function sortEventsChronologically(eventsToSort) {
522
+ return [...eventsToSort].sort((a, b) => {
523
+ const ta = Number(a.time) || 0;
524
+ const tb = Number(b.time) || 0;
525
+ if (ta !== tb) return ta - tb;
526
+ if (a.type === b.type) return 0;
527
+ if (a.type === 'note_off') return -1;
528
+ if (b.type === 'note_off') return 1;
529
+ return 0;
530
+ });
531
+ }
532
+
533
+ function normalizeEventsToZero(rawEvents) {
534
+ if (!Array.isArray(rawEvents) || rawEvents.length === 0) {
535
+ return [];
536
+ }
537
+
538
+ const cleaned = rawEvents
539
+ .filter(e => e && (e.type === 'note_on' || e.type === 'note_off'))
540
+ .map(e => ({
541
+ type: e.type,
542
+ note: Number(e.note) || 0,
543
+ velocity: Number(e.velocity) || 0,
544
+ time: Number(e.time) || 0,
545
+ channel: Number(e.channel) || 0
546
+ }));
547
+
548
+ if (cleaned.length === 0) {
549
+ return [];
550
+ }
551
+
552
+ const minTime = Math.min(...cleaned.map(e => e.time));
553
+ return sortEventsChronologically(
554
+ cleaned.map(e => ({
555
+ ...e,
556
+ time: Math.max(0, e.time - minTime)
557
+ }))
558
+ );
559
+ }
560
+
561
+ function clampMidiNote(note) {
562
+ const minNote = baseMidi;
563
+ const maxNote = baseMidi + (numOctaves * 12) - 1;
564
+ return Math.max(minNote, Math.min(maxNote, note));
565
+ }
566
+
567
+ function eventsToNotePairs(rawEvents) {
568
+ const pairs = [];
569
+ const activeByNote = new Map();
570
+ const sorted = sortEventsChronologically(rawEvents);
571
+
572
+ sorted.forEach(event => {
573
+ const note = Number(event.note) || 0;
574
+ const time = Number(event.time) || 0;
575
+ const velocity = Number(event.velocity) || 100;
576
+
577
+ if (event.type === 'note_on' && velocity > 0) {
578
+ if (!activeByNote.has(note)) activeByNote.set(note, []);
579
+ activeByNote.get(note).push({ start: time, velocity });
580
+ return;
581
+ }
582
+
583
+ if (event.type === 'note_off' || (event.type === 'note_on' && velocity <= 0)) {
584
+ const stack = activeByNote.get(note);
585
+ if (!stack || stack.length === 0) return;
586
+ const active = stack.shift();
587
+ const end = Math.max(active.start + 0.05, time);
588
+ pairs.push({
589
+ note: clampMidiNote(note),
590
+ start: active.start,
591
+ end,
592
+ velocity: Math.max(1, Math.min(127, active.velocity))
593
+ });
594
+ }
595
+ });
596
+
597
+ return pairs.sort((a, b) => a.start - b.start);
598
+ }
599
+
600
+ function notePairsToEvents(pairs) {
601
+ const eventsOut = [];
602
+ pairs.forEach(pair => {
603
+ const note = clampMidiNote(Math.round(pair.note));
604
+ const start = Math.max(0, Number(pair.start) || 0);
605
+ const end = Math.max(start + 0.05, Number(pair.end) || start + 0.2);
606
+ const velocity = Math.max(1, Math.min(127, Math.round(Number(pair.velocity) || 100)));
607
+
608
+ eventsOut.push({
609
+ type: 'note_on',
610
+ note,
611
+ velocity,
612
+ time: start,
613
+ channel: 0
614
+ });
615
+ eventsOut.push({
616
+ type: 'note_off',
617
+ note,
618
+ velocity: 0,
619
+ time: end,
620
+ channel: 0
621
+ });
622
+ });
623
+ return sortEventsChronologically(eventsOut);
624
+ }
625
+
626
+ function trimNotePairs(pairs, maxNotes, maxDurationSec) {
627
+ const out = [];
628
+ for (let i = 0; i < pairs.length; i++) {
629
+ if (out.length >= maxNotes) break;
630
+ if (pairs[i].start > maxDurationSec) break;
631
+ const boundedEnd = Math.min(pairs[i].end, maxDurationSec);
632
+ out.push({
633
+ ...pairs[i],
634
+ end: Math.max(pairs[i].start + 0.05, boundedEnd)
635
+ });
636
+ }
637
+ return out;
638
+ }
639
+
640
+ function smoothPairLeaps(pairs, maxLeapSemitones = 7) {
641
+ if (pairs.length <= 1) return pairs;
642
+ const smoothed = [{ ...pairs[0], note: clampMidiNote(pairs[0].note) }];
643
+ for (let i = 1; i < pairs.length; i++) {
644
+ const prev = smoothed[i - 1].note;
645
+ let current = pairs[i].note;
646
+ while (Math.abs(current - prev) > maxLeapSemitones) {
647
+ current += current > prev ? -12 : 12;
648
+ }
649
+ smoothed.push({
650
+ ...pairs[i],
651
+ note: clampMidiNote(current)
652
+ });
653
+ }
654
+ return smoothed;
655
+ }
656
+
657
+ function appendMotifEcho(pairs, callEvents, maxDurationSec) {
658
+ const callPitches = normalizeEventsToZero(callEvents)
659
+ .filter(e => e.type === 'note_on' && e.velocity > 0)
660
+ .map(e => clampMidiNote(Number(e.note) || 0))
661
+ .slice(0, 2);
662
+
663
+ if (callPitches.length === 0) return pairs;
664
+
665
+ let nextStart = pairs.length > 0 ? pairs[pairs.length - 1].end + 0.1 : 0.2;
666
+ const out = [...pairs];
667
+ callPitches.forEach((pitch, idx) => {
668
+ const start = nextStart + (idx * 0.28);
669
+ if (start >= maxDurationSec) return;
670
+ out.push({
671
+ note: pitch,
672
+ start,
673
+ end: Math.min(maxDurationSec, start + 0.22),
674
+ velocity: 96
675
+ });
676
+ });
677
+ return out;
678
+ }
679
+
680
+ function applyPlayfulShift(pairs) {
681
+ return pairs.map((pair, idx) => {
682
+ if (idx % 2 === 0) return pair;
683
+ const direction = idx % 4 === 1 ? 2 : -2;
684
+ return {
685
+ ...pair,
686
+ note: clampMidiNote(pair.note + direction)
687
+ };
688
+ });
689
+ }
690
+
691
+ // Generic preset getter - consolidates 3 similar functions
692
+ function getSelectedPreset(selectElement, presetMap, defaultKey, idKey) {
693
+ const id = selectElement ? selectElement.value : defaultKey;
694
+ return {
695
+ [idKey]: id,
696
+ ...(presetMap[id] || presetMap[defaultKey])
697
+ };
698
+ }
699
+
700
+ function getSelectedStylePreset() {
701
+ return getSelectedPreset(responseStyleSelect, RESPONSE_STYLE_PRESETS, 'melodic', 'styleId');
702
+ }
703
+
704
+ function getSelectedResponseMode() {
705
+ return getSelectedPreset(responseModeSelect, RESPONSE_MODES, 'raw_godzilla', 'modeId');
706
+ }
707
+
708
+ function getSelectedResponseLengthPreset() {
709
+ return getSelectedPreset(responseLengthSelect, RESPONSE_LENGTH_PRESETS, 'short', 'lengthId');
710
+ }
711
+
712
+ function getDecodingOptionsForMode(modeId) {
713
+ if (modeId === 'raw_godzilla') {
714
+ return { temperature: 1.0, top_p: 0.98, num_candidates: 1 };
715
+ }
716
+ if (modeId === 'musical_polish') {
717
+ return { temperature: 0.85, top_p: 0.93, num_candidates: 4 };
718
+ }
719
+ return { temperature: 0.9, top_p: 0.95, num_candidates: 3 };
720
+ }
721
+
722
+ function getSelectedDecodingOptions() {
723
+ const mode = getSelectedResponseMode();
724
+ return getDecodingOptionsForMode(mode.modeId);
725
+ }
726
+
727
+ function getSelectedRuntime() {
728
+ if (!runtimeSelect || !runtimeSelect.value) return 'auto';
729
+ return runtimeSelect.value;
730
+ }
731
+
732
+ function quantizeToStep(value, step) {
733
+ if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) {
734
+ return value;
735
+ }
736
+ return Math.round(value / step) * step;
737
+ }
738
+
739
+ function moveByOctaveTowardTarget(note, target) {
740
+ let candidate = note;
741
+ while (candidate + 12 <= target) {
742
+ candidate += 12;
743
+ }
744
+ while (candidate - 12 >= target) {
745
+ candidate -= 12;
746
+ }
747
+ const up = clampMidiNote(candidate + 12);
748
+ const down = clampMidiNote(candidate - 12);
749
+ const current = clampMidiNote(candidate);
750
+ const best = [current, up, down].reduce((winner, value) => {
751
+ return Math.abs(value - target) < Math.abs(winner - target) ? value : winner;
752
+ }, current);
753
+ return clampMidiNote(best);
754
+ }
755
+
756
+ function getCallProfile(callEvents) {
757
+ const normalizedCall = normalizeEventsToZero(callEvents);
758
+ const pitches = normalizedCall
759
+ .filter(e => e.type === 'note_on' && e.velocity > 0)
760
+ .map(e => clampMidiNote(Number(e.note) || baseMidi));
761
+ const velocities = normalizedCall
762
+ .filter(e => e.type === 'note_on' && e.velocity > 0)
763
+ .map(e => Math.max(1, Math.min(127, Number(e.velocity) || 100)));
764
+
765
+ const keyboardCenter = baseMidi + Math.floor((numOctaves * 12) / 2);
766
+ const center = pitches.length > 0
767
+ ? pitches.reduce((sum, value) => sum + value, 0) / pitches.length
768
+ : keyboardCenter;
769
+ const finalPitch = pitches.length > 0 ? pitches[pitches.length - 1] : keyboardCenter;
770
+ const avgVelocity = velocities.length > 0
771
+ ? velocities.reduce((sum, value) => sum + value, 0) / velocities.length
772
+ : 100;
773
+
774
+ return { pitches, center, finalPitch, avgVelocity };
775
+ }
776
+
777
+ function applyResponseStyle(rawResponseEvents, callEvents, lengthPreset) {
778
+ const preset = getSelectedStylePreset();
779
+ const targetMaxNotes = Math.max(preset.maxNotes, lengthPreset.maxNotes);
780
+ const targetMaxDuration = Math.max(preset.maxDurationSec, lengthPreset.maxDurationSec);
781
+ let notePairs = eventsToNotePairs(normalizeEventsToZero(rawResponseEvents));
782
+ notePairs = trimNotePairs(notePairs, targetMaxNotes, targetMaxDuration);
783
+ if (preset.playfulShift) {
784
+ notePairs = applyPlayfulShift(notePairs);
785
+ }
786
+ if (preset.smoothLeaps) {
787
+ notePairs = smoothPairLeaps(notePairs);
788
+ }
789
+ if (preset.addMotifEcho) {
790
+ notePairs = appendMotifEcho(notePairs, callEvents, targetMaxDuration);
791
+ notePairs = trimNotePairs(notePairs, targetMaxNotes, targetMaxDuration);
792
+ }
793
+ return {
794
+ styleLabel: preset.label,
795
+ events: notePairsToEvents(notePairs)
796
+ };
797
+ }
798
+
799
+ function applyMusicalPolish(rawResponseEvents, callEvents, lengthPreset) {
800
+ const stylePreset = getSelectedStylePreset();
801
+ const callProfile = getCallProfile(callEvents);
802
+ let notePairs = eventsToNotePairs(normalizeEventsToZero(rawResponseEvents));
803
+
804
+ if (notePairs.length === 0) {
805
+ const fallbackPitches = callProfile.pitches.slice(0, 4);
806
+ if (fallbackPitches.length === 0) {
807
+ return [];
808
+ }
809
+ notePairs = fallbackPitches.map((pitch, idx) => {
810
+ const start = idx * 0.28;
811
+ return {
812
+ note: clampMidiNote(pitch),
813
+ start,
814
+ end: start + 0.24,
815
+ velocity: Math.round(callProfile.avgVelocity)
816
+ };
817
+ });
818
+ }
819
+
820
+ const polished = [];
821
+ let previousStart = -1;
822
+ for (let i = 0; i < notePairs.length; i++) {
823
+ const source = notePairs[i];
824
+ let note = moveByOctaveTowardTarget(source.note, callProfile.center);
825
+ if (polished.length > 0) {
826
+ const prev = polished[polished.length - 1].note;
827
+ while (Math.abs(note - prev) > 7) {
828
+ note += note > prev ? -12 : 12;
829
+ }
830
+ note = clampMidiNote(note);
831
+ }
832
+
833
+ const quantizedStart = Math.max(0, quantizeToStep(source.start, 0.125));
834
+ const start = Math.max(quantizedStart, previousStart + 0.06);
835
+ previousStart = start;
836
+
837
+ const rawDur = Math.max(0.1, source.end - source.start);
838
+ const duration = Math.max(0.12, Math.min(0.9, quantizeToStep(rawDur, 0.0625)));
839
+ const velocity = Math.round(
840
+ (Math.max(1, Math.min(127, source.velocity)) * 0.6)
841
+ + (callProfile.avgVelocity * 0.4)
842
+ );
843
+
844
+ polished.push({
845
+ note,
846
+ start,
847
+ end: start + duration,
848
+ velocity: Math.max(1, Math.min(127, velocity))
849
+ });
850
+ }
851
+
852
+ if (polished.length > 0) {
853
+ polished[polished.length - 1].note = moveByOctaveTowardTarget(
854
+ polished[polished.length - 1].note,
855
+ callProfile.finalPitch
856
+ );
857
+ }
858
+
859
+ let out = trimNotePairs(polished, lengthPreset.maxNotes, lengthPreset.maxDurationSec);
860
+ if (stylePreset.addMotifEcho) {
861
+ out = appendMotifEcho(out, callEvents, lengthPreset.maxDurationSec);
862
+ }
863
+ if (stylePreset.playfulShift) {
864
+ out = applyPlayfulShift(out);
865
+ }
866
+ out = smoothPairLeaps(out, 6);
867
+ out = trimNotePairs(out, lengthPreset.maxNotes, lengthPreset.maxDurationSec);
868
+ return out;
869
+ }
870
+
871
+ function buildProcessedAIResponse(rawResponseEvents, callEvents) {
872
+ const mode = getSelectedResponseMode();
873
+ const lengthPreset = getSelectedResponseLengthPreset();
874
+
875
+ if (mode.modeId === 'raw_godzilla') {
876
+ return {
877
+ label: `${mode.label} (${lengthPreset.label})`,
878
+ events: normalizeEventsToZero(rawResponseEvents || [])
879
+ };
880
+ }
881
+
882
+ if (mode.modeId === 'musical_polish') {
883
+ return {
884
+ label: `${mode.label} (${lengthPreset.label})`,
885
+ events: notePairsToEvents(applyMusicalPolish(rawResponseEvents || [], callEvents, lengthPreset))
886
+ };
887
+ }
888
+
889
+ const styled = applyResponseStyle(rawResponseEvents || [], callEvents, lengthPreset);
890
+ return {
891
+ label: `${mode.label} / ${styled.styleLabel} (${lengthPreset.label})`,
892
+ events: styled.events
893
+ };
894
+ }
895
+
896
  // =============================================================================
897
  // TERMINAL LOGGING
898
  // =============================================================================
 
1008
  return keyboardEl.querySelector(`.key[data-midi="${midiNote}"]`);
1009
  }
1010
 
1011
+ function bindGlobalKeyboardHandlers() {
1012
+ document.addEventListener('keydown', async (ev) => {
1013
+ if (!keyboardToggle || !keyboardToggle.checked) return;
1014
+ const key = ev.key.toLowerCase();
1015
+ const activeKeyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
1016
+ if (!activeKeyMap[key] || pressedKeys.has(key)) return;
1017
+
1018
+ ev.preventDefault();
1019
+ pressedKeys.add(key);
1020
+
1021
+ await Tone.start();
1022
+
1023
+ const midiNote = activeKeyMap[key];
1024
+ const keyEl = getKeyElement(midiNote);
1025
+ if (keyEl) keyEl.style.filter = 'brightness(0.85)';
1026
+ noteOn(midiNote, 100);
1027
+ });
1028
+
1029
+ document.addEventListener('keyup', (ev) => {
1030
+ if (!keyboardToggle || !keyboardToggle.checked) return;
1031
+ const key = ev.key.toLowerCase();
1032
+ const activeKeyMap = window.keyMapFromServer || keyMap; // Use server config if available, fallback to hardcoded
1033
+ if (!activeKeyMap[key] || !pressedKeys.has(key)) return;
1034
+
1035
+ ev.preventDefault();
1036
+ pressedKeys.delete(key);
1037
+
1038
+ const midiNote = activeKeyMap[key];
1039
+ const keyEl = getKeyElement(midiNote);
1040
+ if (keyEl) keyEl.style.filter = '';
1041
+ noteOff(midiNote);
1042
+ });
1043
+ }
1044
 
1045
  // =============================================================================
1046
  // MOUSE/TOUCH INPUT
 
1099
  saveBtn.disabled = true;
1100
 
1101
  try {
1102
+ const payload = await callGradioBridge('save_midi', events);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1103
  if (!payload || payload.error || !payload.midi_base64) {
1104
  throw new Error(payload && payload.error ? payload.error : 'Invalid API response');
1105
  }
 
1126
  }
1127
  }
1128
 
1129
+ function clearGameTimers() {
1130
+ if (gameTurnTimerId !== null) {
1131
+ clearInterval(gameTurnTimerId);
1132
+ gameTurnTimerId = null;
1133
+ }
1134
+ if (gameTurnTimeoutId !== null) {
1135
+ clearTimeout(gameTurnTimeoutId);
1136
+ gameTurnTimeoutId = null;
1137
+ }
1138
+ }
1139
 
1140
+ async function processEventsThroughEngine(inputEvents, options = {}) {
1141
+ const selectedEngineId = engineSelect.value;
1142
+ if (!selectedEngineId || selectedEngineId === 'parrot') {
1143
+ return { events: inputEvents };
1144
+ }
1145
 
1146
+ const requestOptions = { ...options };
1147
+ const runtimeMode = getSelectedRuntime();
1148
+ if (
1149
+ selectedEngineId === 'godzilla_continue'
1150
+ && typeof requestOptions.generate_tokens !== 'number'
1151
+ ) {
1152
+ requestOptions.generate_tokens = RESPONSE_LENGTH_PRESETS.medium.generateTokens;
 
 
 
 
 
 
 
 
1153
  }
 
1154
 
1155
+ let bridgeAction = 'process_engine_cpu';
1156
+ if (selectedEngineId === 'godzilla_continue') {
1157
+ if (runtimeMode === 'gpu' || runtimeMode === 'auto') {
1158
+ bridgeAction = 'process_engine_gpu';
1159
+ }
1160
+ }
 
 
1161
 
1162
+ const requestPayload = {
1163
+ engine_id: selectedEngineId,
1164
+ events: inputEvents,
1165
+ options: requestOptions
1166
+ };
1167
 
1168
+ let result;
1169
+ try {
1170
+ result = await callGradioBridge(bridgeAction, requestPayload);
1171
+ } catch (err) {
1172
+ if (
1173
+ selectedEngineId === 'godzilla_continue'
1174
+ && runtimeMode === 'auto'
1175
+ && bridgeAction === 'process_engine_gpu'
1176
+ ) {
1177
+ logToTerminal('Runtime auto: ZeroGPU failed, retrying on CPU.', 'timestamp');
1178
+ result = await callGradioBridge('process_engine_cpu', requestPayload);
1179
+ } else {
1180
+ throw err;
1181
+ }
1182
+ }
1183
 
1184
+ if (
1185
+ result
1186
+ && result.error
1187
+ && selectedEngineId === 'godzilla_continue'
1188
+ && runtimeMode === 'auto'
1189
+ && bridgeAction === 'process_engine_gpu'
1190
+ ) {
1191
+ logToTerminal(`Runtime auto: ZeroGPU error (${result.error}), retrying on CPU.`, 'timestamp');
1192
+ result = await callGradioBridge('process_engine_cpu', requestPayload);
1193
+ }
1194
 
1195
+ if (result && result.error) {
1196
+ throw new Error(result.error);
1197
+ }
1198
+ if (!result || !Array.isArray(result.events)) {
1199
+ throw new Error('Engine returned no events');
1200
+ }
1201
+ if (result.warning) {
1202
+ logToTerminal(`ENGINE WARNING: ${result.warning}`, 'timestamp');
1203
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1204
 
1205
+ return result;
1206
+ }
1207
+
1208
+ function playEvents(eventsToPlay, { logSymbols = true, useAISynth = false } = {}) {
1209
+ return new Promise((resolve) => {
1210
+ if (!Array.isArray(eventsToPlay) || eventsToPlay.length === 0) {
1211
+ resolve();
1212
+ return;
 
 
 
 
 
 
 
 
 
 
1213
  }
1214
+
1215
+ const playbackSynth = useAISynth && aiSynth ? aiSynth : synth;
 
1216
  let eventIndex = 0;
1217
+
1218
  const playEvent = () => {
1219
+ if (eventIndex >= eventsToPlay.length) {
1220
+ if (playbackSynth) playbackSynth.releaseAll();
 
 
 
 
 
1221
  keyboardEl.querySelectorAll('.key').forEach(k => {
1222
  k.style.filter = '';
1223
  });
1224
+ resolve();
 
 
 
 
 
 
 
1225
  return;
1226
  }
1227
+
1228
+ const event = eventsToPlay[eventIndex];
1229
+ const nextTime = eventIndex + 1 < eventsToPlay.length
1230
+ ? eventsToPlay[eventIndex + 1].time
1231
  : event.time;
1232
+
1233
  if (event.type === 'note_on') {
1234
  const freq = Tone.Frequency(event.note, "midi").toFrequency();
1235
+ if (playbackSynth) {
1236
+ playbackSynth.triggerAttack(freq, undefined, event.velocity / 127);
1237
+ }
1238
+ if (logSymbols) {
1239
+ const noteName = midiToNoteName(event.note);
1240
+ logToTerminal(
1241
+ `[${event.time.toFixed(3)}s] ► ${noteName} (${event.note})`,
1242
+ 'note-on'
1243
+ );
1244
+ }
1245
  const keyEl = getKeyElement(event.note);
1246
  if (keyEl) keyEl.style.filter = 'brightness(0.7)';
1247
  } else if (event.type === 'note_off') {
1248
  const freq = Tone.Frequency(event.note, "midi").toFrequency();
1249
+ if (playbackSynth) {
1250
+ playbackSynth.triggerRelease(freq);
1251
+ }
1252
+ if (logSymbols) {
1253
+ const noteName = midiToNoteName(event.note);
1254
+ logToTerminal(
1255
+ `[${event.time.toFixed(3)}s] ◄ ${noteName}`,
1256
+ 'note-off'
1257
+ );
1258
+ }
1259
  const keyEl = getKeyElement(event.note);
1260
  if (keyEl) keyEl.style.filter = '';
1261
  }
1262
+
1263
  eventIndex++;
1264
  const deltaTime = Math.max(0, nextTime - event.time);
1265
  setTimeout(playEvent, deltaTime * 1000);
1266
  };
1267
+
1268
  playEvent();
1269
+ });
1270
+ }
1271
+
1272
+ async function startGameLoop() {
1273
+ if (gameActive) return;
1274
+ await Tone.start();
1275
+
1276
+ if (engineSelect.querySelector('option[value="godzilla_continue"]')) {
1277
+ engineSelect.value = 'godzilla_continue';
1278
+ selectedEngine = 'godzilla_continue';
1279
+ }
1280
+
1281
+ gameActive = true;
1282
+ gameTurn = 0;
1283
+ gameStartBtn.disabled = true;
1284
+ gameStopBtn.disabled = false;
1285
+ recordBtn.disabled = true;
1286
+ stopBtn.disabled = true;
1287
+ playbackBtn.disabled = true;
1288
+ saveBtn.disabled = true;
1289
+ statusEl.textContent = 'Game started';
1290
+
1291
+ logToTerminal('', '');
1292
+ logToTerminal('🎮 CALL & RESPONSE GAME STARTED', 'timestamp');
1293
+ logToTerminal(
1294
+ `Flow: ${USER_TURN_LIMIT_SEC}s call, AI response, repeat until you stop.`,
1295
+ 'timestamp'
1296
+ );
1297
+ const stylePreset = getSelectedStylePreset();
1298
+ const modePreset = getSelectedResponseMode();
1299
+ const lengthPreset = getSelectedResponseLengthPreset();
1300
+ const decodingPreset = getSelectedDecodingOptions();
1301
+ logToTerminal(
1302
+ `AI mode: ${modePreset.label} | length: ${lengthPreset.label} | style: ${stylePreset.label}`,
1303
+ 'timestamp'
1304
+ );
1305
+ logToTerminal(
1306
+ `Decoding: temp=${decodingPreset.temperature} top_p=${decodingPreset.top_p} candidates=${decodingPreset.num_candidates}`,
1307
+ 'timestamp'
1308
+ );
1309
+ logToTerminal('', '');
1310
+
1311
+ await startUserTurn();
1312
+ }
1313
+
1314
+ function stopGameLoop(reason = 'Game stopped') {
1315
+ clearGameTimers();
1316
+ if (recording) {
1317
+ stopRecord();
1318
+ }
1319
+ gameActive = false;
1320
+ gameStartBtn.disabled = false;
1321
+ gameStopBtn.disabled = true;
1322
+ recordBtn.disabled = false;
1323
+ stopBtn.disabled = true;
1324
+ playbackBtn.disabled = events.length === 0;
1325
+ saveBtn.disabled = events.length === 0;
1326
+ statusEl.textContent = reason;
1327
+ if (synth) synth.releaseAll();
1328
+ if (aiSynth) aiSynth.releaseAll();
1329
+ keyboardEl.querySelectorAll('.key').forEach(k => {
1330
+ k.style.filter = '';
1331
+ });
1332
+ logToTerminal(`🎮 ${reason}`, 'timestamp');
1333
+ }
1334
+
1335
+ async function startUserTurn() {
1336
+ if (!gameActive) return;
1337
+ clearGameTimers();
1338
+
1339
+ gameTurn += 1;
1340
+ beginRecord();
1341
+ gameStartBtn.disabled = true;
1342
+ gameStopBtn.disabled = false;
1343
+ recordBtn.disabled = true;
1344
+ stopBtn.disabled = true;
1345
+ playbackBtn.disabled = true;
1346
+ saveBtn.disabled = true;
1347
+
1348
+ let remaining = USER_TURN_LIMIT_SEC;
1349
+ statusEl.textContent = `Turn ${gameTurn}: your call (${remaining}s)`;
1350
+ logToTerminal(`Turn ${gameTurn}: your call starts now`, 'timestamp');
1351
+
1352
+ gameTurnTimerId = setInterval(() => {
1353
+ if (!gameActive) return;
1354
+ remaining -= 1;
1355
+ if (remaining > 0) {
1356
+ statusEl.textContent = `Turn ${gameTurn}: your call (${remaining}s)`;
1357
  }
1358
+ }, 1000);
1359
+
1360
+ gameTurnTimeoutId = setTimeout(() => {
1361
+ void finishUserTurn();
1362
+ }, USER_TURN_LIMIT_SEC * 1000);
1363
+ }
1364
+
1365
+ async function finishUserTurn() {
1366
+ if (!gameActive) return;
1367
+ clearGameTimers();
1368
+ if (recording) stopRecord();
1369
+ recordBtn.disabled = true;
1370
+ stopBtn.disabled = true;
1371
+ playbackBtn.disabled = true;
1372
+ saveBtn.disabled = true;
1373
+
1374
+ const callEvents = [...events];
1375
+ if (callEvents.length === 0) {
1376
+ statusEl.textContent = `Turn ${gameTurn}: no notes, try again`;
1377
+ logToTerminal('No notes captured, restarting your turn...', 'timestamp');
1378
+ setTimeout(() => {
1379
+ void startUserTurn();
1380
+ }, GAME_NEXT_TURN_DELAY_MS);
1381
+ return;
1382
  }
 
1383
 
1384
+ try {
1385
+ statusEl.textContent = `Turn ${gameTurn}: AI thinking...`;
1386
+ logToTerminal(`Turn ${gameTurn}: AI is thinking...`, 'timestamp');
1387
 
1388
+ const lengthPreset = getSelectedResponseLengthPreset();
1389
+ const promptEvents = normalizeEventsToZero(callEvents);
1390
+ const decodingOptions = getSelectedDecodingOptions();
1391
+ const result = await processEventsThroughEngine(promptEvents, {
1392
+ generate_tokens: lengthPreset.generateTokens,
1393
+ ...decodingOptions
1394
+ });
1395
+ const processedResponse = buildProcessedAIResponse(result.events || [], callEvents);
1396
+ const aiEvents = processedResponse.events;
1397
+
1398
+ if (!gameActive) return;
1399
+
1400
+ statusEl.textContent = `Turn ${gameTurn}: AI responds`;
1401
+ logToTerminal(
1402
+ `Turn ${gameTurn}: AI response (${processedResponse.label})`,
1403
+ 'timestamp'
1404
+ );
1405
+ await playEvents(aiEvents, { useAISynth: true });
1406
+
1407
+ if (!gameActive) return;
1408
+ setTimeout(() => {
1409
+ void startUserTurn();
1410
+ }, GAME_NEXT_TURN_DELAY_MS);
1411
+ } catch (err) {
1412
+ console.error('Game turn error:', err);
1413
+ logToTerminal(`ENGINE ERROR: ${err.message}`, 'timestamp');
1414
+ stopGameLoop(`Game stopped: ${err.message}`);
1415
  }
1416
+ }
1417
+
1418
+ // =============================================================================
1419
+ // HELPER FUNCTIONS
1420
+ // =============================================================================
1421
+
1422
+ // Reset all audio synthesis and visual key states
1423
+ function resetAllNotesAndVisuals() {
1424
+ if (synth) synth.releaseAll();
1425
+ if (aiSynth) aiSynth.releaseAll();
1426
  keyboardEl.querySelectorAll('.key').forEach(k => {
1427
  k.style.filter = '';
1428
  });
1429
+ }
1430
+
1431
+ // =============================================================================
1432
+ // EVENT LISTENERS
1433
+ // =============================================================================
1434
+
1435
+ let listenersBound = false;
1436
+
1437
+ function bindUIEventListeners() {
1438
+ if (listenersBound) return;
1439
+ listenersBound = true;
1440
+
1441
+ bindGlobalKeyboardHandlers();
1442
+
1443
+ if (instrumentSelect) {
1444
+ instrumentSelect.addEventListener('change', () => {
1445
+ loadInstrument(instrumentSelect.value);
1446
+ });
1447
+ }
1448
+
1449
+ if (aiInstrumentSelect) {
1450
+ aiInstrumentSelect.addEventListener('change', () => {
1451
+ loadAIInstrument(aiInstrumentSelect.value);
1452
+ logToTerminal(`AI voice switched to: ${aiInstrumentSelect.value}`, 'timestamp');
1453
+ });
1454
+ }
1455
+
1456
+ if (keyboardToggle) {
1457
+ keyboardToggle.addEventListener('change', () => {
1458
+ if (keyboardToggle.checked) {
1459
+ // Show keyboard shortcuts
1460
+ keyboardEl.classList.add('shortcuts-visible');
1461
+ } else {
1462
+ // Hide keyboard shortcuts
1463
+ keyboardEl.classList.remove('shortcuts-visible');
1464
+ // Release all currently pressed keyboard keys
1465
+ pressedKeys.forEach(key => {
1466
+ const activeKeyMap = window.keyMapFromServer || keyMap;
1467
+ const midiNote = activeKeyMap[key];
1468
+ const keyEl = getKeyElement(midiNote);
1469
+ if (keyEl) keyEl.style.filter = '';
1470
+ noteOff(midiNote);
1471
+ });
1472
+ pressedKeys.clear();
1473
+ }
1474
+ });
1475
+ }
1476
+
1477
+ if (clearTerminal) {
1478
+ clearTerminal.addEventListener('click', () => {
1479
+ terminal.innerHTML = '';
1480
+ logToTerminal('╔═══════════════════════════════════════════════════════╗', 'timestamp');
1481
+ logToTerminal('║ 🎹 MIDI MONITOR INITIALIZED 🎹 ║', 'timestamp');
1482
+ logToTerminal('╚═══════════════════════════════════════════════════════╝', 'timestamp');
1483
+ logToTerminal('Ready to capture MIDI events...', 'timestamp');
1484
+ logToTerminal('', '');
1485
+ });
1486
+ }
1487
+
1488
+ if (recordBtn) {
1489
+ recordBtn.addEventListener('click', async () => {
1490
+ if (gameActive) return;
1491
+ await Tone.start();
1492
+ beginRecord();
1493
+ });
1494
+ }
1495
+
1496
+ if (stopBtn) {
1497
+ stopBtn.addEventListener('click', () => {
1498
+ if (gameActive) return;
1499
+ stopRecord();
1500
+ });
1501
+ }
1502
+
1503
+ if (engineSelect) {
1504
+ engineSelect.addEventListener('change', (e) => {
1505
+ selectedEngine = e.target.value;
1506
+ logToTerminal(`Engine switched to: ${selectedEngine}`, 'timestamp');
1507
+ });
1508
+ }
1509
+
1510
+ // Consolidated select control listeners
1511
+ const selectControls = [
1512
+ {
1513
+ element: runtimeSelect,
1514
+ getter: () => {
1515
+ const mode = getSelectedRuntime();
1516
+ const label = mode === 'gpu' ? 'ZeroGPU' : (mode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU');
1517
+ return { label };
1518
+ },
1519
+ message: (result) => `Runtime switched to: ${result.label}`
1520
+ },
1521
+ {
1522
+ element: responseStyleSelect,
1523
+ getter: getSelectedStylePreset,
1524
+ message: (result) => `AI style switched to: ${result.label}`
1525
+ },
1526
+ {
1527
+ element: responseModeSelect,
1528
+ getter: () => {
1529
+ const mode = getSelectedResponseMode();
1530
+ const decode = getSelectedDecodingOptions();
1531
+ return { label: `${mode.label} (temp=${decode.temperature}, top_p=${decode.top_p}, candidates=${decode.num_candidates})` };
1532
+ },
1533
+ message: (result) => `Response mode switched to: ${result.label}`
1534
+ },
1535
+ {
1536
+ element: responseLengthSelect,
1537
+ getter: () => {
1538
+ const preset = getSelectedResponseLengthPreset();
1539
+ return { label: `${preset.label} (${preset.generateTokens} tokens)` };
1540
+ },
1541
+ message: (result) => `Response length switched to: ${result.label}`
1542
+ }
1543
+ ];
1544
+
1545
+ selectControls.forEach(({ element, getter, message }) => {
1546
+ if (element) {
1547
+ element.addEventListener('change', () => {
1548
+ const result = getter();
1549
+ logToTerminal(message(result), 'timestamp');
1550
+ });
1551
+ }
1552
+ });
1553
+
1554
+ // Setup hover tooltips for control items
1555
+ const setupControlItemHoverListeners = () => {
1556
+ const controlIdToName = {
1557
+ 'engine': 'Engine',
1558
+ 'runtime': 'Runtime',
1559
+ 'aiStyle': 'AI Style',
1560
+ 'responseMode': 'Response Mode',
1561
+ 'responseLength': 'Response Length',
1562
+ 'instrument': 'Instrument',
1563
+ 'aiVoice': 'AI Voice'
1564
+ };
1565
+
1566
+ const updateTooltipDisplay = (html) => {
1567
+ const tooltipDisplay = document.getElementById('tooltipDisplay');
1568
+ if (tooltipDisplay) {
1569
+ tooltipDisplay.innerHTML = html;
1570
+ }
1571
+ };
1572
+
1573
+ const controlItems = document.querySelectorAll('[data-control-id]');
1574
+ controlItems.forEach(label => {
1575
+ const select = label.querySelector('select');
1576
+ if (!select) return;
1577
+
1578
+ const controlId = label.getAttribute('data-control-id');
1579
+ const controlName = controlIdToName[controlId] || controlId;
1580
+ const description = select.getAttribute('data-description');
1581
+ const showOption = ['engine', 'runtime', 'aiStyle', 'responseMode', 'responseLength'].includes(controlId);
1582
+
1583
+ const updateDisplay = () => {
1584
+ if (showOption) {
1585
+ const selectedOption = select.querySelector(`option[value="${select.value}"]`);
1586
+ if (selectedOption) {
1587
+ const optionName = selectedOption.textContent;
1588
+ const tooltip = selectedOption.getAttribute('data-tooltip');
1589
+ if (tooltip && description) {
1590
+ updateTooltipDisplay(`<b>${controlName}</b>: ${description} - <b>${optionName}</b>: ${tooltip}`);
1591
+ } else if (description) {
1592
+ updateTooltipDisplay(`<b>${controlName}</b>: ${description}`);
1593
+ }
1594
+ }
1595
+ } else if (description) {
1596
+ updateTooltipDisplay(`<b>${controlName}</b>: ${description}`);
1597
+ }
1598
+ };
1599
+
1600
+ label.addEventListener('mouseenter', updateDisplay);
1601
+ label.addEventListener('mouseleave', () => {
1602
+ updateTooltipDisplay('');
1603
+ });
1604
+ });
1605
+ };
1606
+ setupControlItemHoverListeners();
1607
+
1608
+ if (gameStartBtn) {
1609
+ gameStartBtn.addEventListener('click', () => {
1610
+ void startGameLoop();
1611
+ });
1612
+ }
1613
+
1614
+ if (gameStopBtn) {
1615
+ gameStopBtn.addEventListener('click', () => {
1616
+ stopGameLoop('Game stopped');
1617
+ });
1618
+ }
1619
+
1620
+ if (playbackBtn) {
1621
+ playbackBtn.addEventListener('click', async () => {
1622
+ if (gameActive) return alert('Stop the game first.');
1623
+ if (events.length === 0) return alert('No recording to play back');
1624
+
1625
+ // Ensure all notes are off before starting playback
1626
+ resetAllNotesAndVisuals();
1627
+
1628
+ statusEl.textContent = 'Playing back...';
1629
+ playbackBtn.disabled = true;
1630
+ recordBtn.disabled = true;
1631
+
1632
+ logToTerminal('', '');
1633
+ logToTerminal('♫♫♫ PLAYBACK STARTED ♫♫♫', 'timestamp');
1634
+ logToTerminal('', '');
1635
+
1636
+ try {
1637
+ let engineOptions = {};
1638
+ if (engineSelect.value === 'godzilla_continue') {
1639
+ const lengthPreset = getSelectedResponseLengthPreset();
1640
+ engineOptions = {
1641
+ generate_tokens: lengthPreset.generateTokens,
1642
+ ...getSelectedDecodingOptions()
1643
+ };
1644
+ }
1645
+ const result = await processEventsThroughEngine(events, engineOptions);
1646
+ let processedEvents = result.events || [];
1647
+ if (engineSelect.value === 'godzilla_continue') {
1648
+ const processedResponse = buildProcessedAIResponse(processedEvents, events);
1649
+ processedEvents = processedResponse.events;
1650
+ logToTerminal(`Playback response mode: ${processedResponse.label}`, 'timestamp');
1651
+ }
1652
+ await playEvents(processedEvents, {
1653
+ useAISynth: engineSelect.value !== 'parrot'
1654
+ });
1655
+
1656
+ statusEl.textContent = 'Playback complete';
1657
+ playbackBtn.disabled = false;
1658
+ recordBtn.disabled = false;
1659
+ logToTerminal('', '');
1660
+ logToTerminal('♫♫♫ PLAYBACK FINISHED ♫♫♫', 'timestamp');
1661
+ logToTerminal('', '');
1662
+ } catch (err) {
1663
+ console.error('Playback error:', err);
1664
+ statusEl.textContent = 'Playback error: ' + err.message;
1665
+ logToTerminal(`ENGINE ERROR: ${err.message}`, 'timestamp');
1666
+ playbackBtn.disabled = false;
1667
+ recordBtn.disabled = false;
1668
+
1669
+ // Ensure all notes are off on error
1670
+ resetAllNotesAndVisuals();
1671
+ }
1672
+ });
1673
+ }
1674
+
1675
+ if (saveBtn) {
1676
+ saveBtn.addEventListener('click', () => saveMIDI());
1677
+ }
1678
+
1679
+ if (panicBtn) {
1680
+ panicBtn.addEventListener('click', () => {
1681
+ // Stop all notes immediately and reset visuals
1682
+ resetAllNotesAndVisuals();
1683
+
1684
+ // Clear all pressed keys
1685
+ pressedKeys.clear();
1686
+
1687
+ logToTerminal('🚨 PANIC - All notes stopped', 'timestamp');
1688
+ });
1689
+ }
1690
+ }
1691
 
1692
  // =============================================================================
1693
  // =============================================================================
 
1695
  // =============================================================================
1696
 
1697
  async function init() {
1698
+ await waitForKeyboardUIElements();
1699
+ await waitForBridgeElements();
1700
+ cacheUIElements();
1701
+ bindUIEventListeners();
1702
+
1703
  // First, load configuration from server
1704
  await initializeFromConfig();
1705
+
1706
+ if (responseStyleSelect && !responseStyleSelect.value) {
1707
+ responseStyleSelect.value = 'melodic';
1708
+ }
1709
+ if (responseModeSelect && !responseModeSelect.value) {
1710
+ responseModeSelect.value = 'raw_godzilla';
1711
+ }
1712
+ if (responseLengthSelect && !responseLengthSelect.value) {
1713
+ responseLengthSelect.value = 'short';
1714
+ }
1715
+ if (runtimeSelect && !runtimeSelect.value) {
1716
+ runtimeSelect.value = 'auto';
1717
+ }
1718
+ if (aiInstrumentSelect && !aiInstrumentSelect.value) {
1719
+ aiInstrumentSelect.value = 'fm';
1720
+ }
1721
 
1722
  // Then load default instrument (synth)
1723
  loadInstrument('synth');
1724
+ loadAIInstrument(aiInstrumentSelect ? aiInstrumentSelect.value : 'fm');
1725
 
1726
  // Setup keyboard event listeners and UI
1727
  attachPointerEvents();
1728
  initTerminal();
1729
+ const runtimeMode = getSelectedRuntime();
1730
+ const runtimeLabel = runtimeMode === 'gpu' ? 'ZeroGPU' : (runtimeMode === 'auto' ? 'Auto (GPU->CPU)' : 'CPU');
1731
+ logToTerminal(`Runtime mode: ${runtimeLabel}`, 'timestamp');
1732
  // Set initial button states
1733
  recordBtn.disabled = false;
1734
  stopBtn.disabled = true;
1735
  saveBtn.disabled = true;
1736
  playbackBtn.disabled = true;
1737
+ gameStartBtn.disabled = false;
1738
+ gameStopBtn.disabled = true;
1739
  }
1740
 
1741
  // Start the application when DOM is ready
1742
+ if (document.readyState === 'loading') {
1743
+ document.addEventListener('DOMContentLoaded', () => {
1744
+ void init();
1745
+ });
1746
+ } else {
1747
+ void init();
1748
+ }
static/styles.css CHANGED
@@ -1,418 +1,894 @@
1
- /* Virtual MIDI Keyboard - Retro Synth Theme */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- /* Layout */
4
- body {
5
- font-family: 'Courier New', 'Consolas', monospace;
6
- padding: 0;
7
  margin: 0;
8
- background: linear-gradient(180deg, #0a0a0f 0%, #1a0a1f 100%);
9
- color: #e0e0ff;
10
  min-height: 100vh;
11
- overflow-x: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
- /* Welcome Header */
15
  .welcome-header {
16
  text-align: center;
17
- padding: 30px 20px 20px;
18
- background: linear-gradient(180deg, rgba(138, 43, 226, 0.1) 0%, transparent 100%);
19
- border-bottom: 2px solid rgba(138, 43, 226, 0.3);
20
  }
21
 
22
- .neon-text {
23
- font-size: 2.5em;
24
- font-weight: bold;
25
- margin: 0 0 10px;
26
- color: #ff00ff;
27
- text-shadow:
28
- 0 0 10px #ff00ff,
29
- 0 0 20px #ff00ff,
30
- 0 0 30px #ff00ff,
31
- 0 0 40px #8b00ff,
32
- 0 0 70px #8b00ff;
33
- letter-spacing: 2px;
34
- }
35
-
36
- #mainContainer {
37
- display: flex;
38
- flex-direction: column;
39
- align-items: center;
40
- gap: 30px;
41
- padding: 20px;
42
- max-width: 1400px;
43
- margin: 0 auto;
44
  }
45
 
46
- .keyboard-section {
47
- width: 100%;
48
- display: flex;
49
- flex-direction: column;
50
- align-items: center;
51
- gap: 15px;
52
  }
53
 
54
- .monitor-section {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  width: 100%;
56
- max-width: 1200px;
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
 
59
- /* Keyboard */
60
- #keyboard {
61
- display: flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  user-select: none;
63
  touch-action: none;
64
- padding: 20px;
65
- background: rgba(0, 0, 0, 0.4);
66
- border-radius: 15px;
67
- border: 2px solid rgba(138, 43, 226, 0.5);
68
- box-shadow:
69
- 0 0 20px rgba(138, 43, 226, 0.3),
70
- inset 0 0 30px rgba(0, 0, 0, 0.5);
71
- }
72
-
73
- .key {
74
- width: 42px;
75
- height: 180px;
76
- border: 2px solid #ff00ff;
77
- margin: 0 1px;
78
- background: linear-gradient(180deg, #fff 0%, #f0f0ff 100%);
79
- position: relative;
80
- display: flex;
81
- align-items: flex-end;
82
- justify-content: center;
83
  cursor: pointer;
84
- touch-action: none;
85
- transition: all 0.1s ease;
86
- box-shadow:
87
- 0 4px 8px rgba(0, 0, 0, 0.3),
88
- inset 0 -2px 5px rgba(0, 0, 0, 0.1);
89
- color: #2a0050;
90
- font-weight: bold;
91
  }
92
 
93
- .key .shortcut-hint {
94
- font-size: 10px;
95
- opacity: 0;
96
- color: #5a00cc;
97
- font-weight: bold;
98
- text-shadow: 0 0 2px rgba(90, 0, 204, 0.3);
99
- transition: opacity 0.2s ease;
100
  }
101
 
102
  .key.black {
103
- color: #00ffff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
  .key.black .shortcut-hint {
107
- color: #00ffff;
108
- text-shadow: 0 0 3px rgba(0, 255, 255, 0.5);
109
  }
110
 
111
  .shortcuts-visible .key .shortcut-hint {
112
  opacity: 1;
113
  }
114
 
115
- .key:hover {
116
- box-shadow:
117
- 0 0 15px rgba(255, 0, 255, 0.5),
118
- inset 0 -2px 5px rgba(0, 0, 0, 0.1);
119
- }
120
-
121
- .key.black {
122
- width: 30px;
123
- height: 110px;
124
- background: linear-gradient(180deg, #1a0a2e 0%, #0a0a1f 100%);
125
- border: 2px solid #8b00ff;
126
- color: #00ffff;
127
- margin-left: -15px;
128
- margin-right: -15px;
129
- z-index: 2;
 
 
 
 
 
 
 
 
 
 
130
  position: relative;
131
- box-shadow:
132
- 0 4px 8px rgba(0, 0, 0, 0.5),
133
- inset 0 -2px 5px rgba(138, 43, 226, 0.3);
 
 
 
 
 
 
 
 
 
134
  }
135
 
136
- .key.black:hover {
137
- box-shadow:
138
- 0 0 15px rgba(0, 255, 255, 0.5),
139
- inset 0 -2px 5px rgba(138, 43, 226, 0.3);
 
 
140
  }
141
 
142
- /* Controls */
143
- .controls {
144
- display: flex;
145
- gap: 10px;
146
- align-items: center;
147
- flex-wrap: wrap;
148
- justify-content: center;
149
- padding: 15px;
150
- background: rgba(0, 0, 0, 0.3);
151
- border-radius: 10px;
152
- border: 1px solid rgba(138, 43, 226, 0.3);
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
155
- .controls label {
156
- color: #00ffff;
157
- font-weight: bold;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  display: flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  align-items: center;
160
- gap: 5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  }
162
 
163
  select,
164
- button {
165
- padding: 8px 16px;
166
- background: linear-gradient(180deg, #8b00ff 0%, #5a00cc 100%);
167
- color: #00ffff;
168
- border: 2px solid #ff00ff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  border-radius: 5px;
170
- font-family: 'Courier New', monospace;
171
- font-weight: bold;
 
172
  cursor: pointer;
173
- transition: all 0.2s ease;
174
- box-shadow: 0 0 10px rgba(138, 43, 226, 0.3);
175
  }
176
 
177
- select:hover,
178
- button:hover:not(:disabled) {
179
- background: linear-gradient(180deg, #a000ff 0%, #7000ee 100%);
180
- box-shadow:
181
- 0 0 20px rgba(255, 0, 255, 0.6),
182
- 0 0 30px rgba(138, 43, 226, 0.4);
183
- transform: translateY(-1px);
 
 
 
 
 
184
  }
185
 
186
- button:active:not(:disabled) {
187
- transform: translateY(0);
188
- box-shadow: 0 0 10px rgba(138, 43, 226, 0.3);
 
 
189
  }
190
 
191
- button:disabled {
192
- opacity: 0.3;
193
- cursor: not-allowed;
 
 
 
 
 
 
 
194
  }
195
 
196
- input[type="checkbox"] {
 
 
 
 
 
 
197
  cursor: pointer;
198
- width: 18px;
199
- height: 18px;
200
- accent-color: #ff00ff;
201
  }
202
 
203
- #status {
204
- color: #00ff00;
205
- font-weight: bold;
206
- text-shadow: 0 0 5px #00ff00;
207
- padding: 5px 10px;
208
- background: rgba(0, 0, 0, 0.5);
209
- border-radius: 5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
 
212
- /* MIDI Terminal */
213
- .terminal-header {
214
- display: flex;
215
- justify-content: space-between;
216
- align-items: center;
217
- padding: 10px 15px;
218
- background: linear-gradient(90deg, rgba(138, 43, 226, 0.2) 0%, rgba(0, 255, 255, 0.1) 100%);
219
- border: 2px solid rgba(138, 43, 226, 0.5);
220
- border-bottom: none;
221
- border-radius: 10px 10px 0 0;
222
  }
223
 
224
  .terminal-header h4 {
225
  margin: 0;
226
- color: #ff00ff;
227
- font-size: 1.1em;
228
- letter-spacing: 2px;
229
- text-shadow: 0 0 10px #ff00ff;
230
- }
231
-
232
- .terminal-header button {
233
- padding: 6px 12px;
234
- font-size: 0.85em;
235
- }
236
-
237
- #terminal {
238
- background: #0a0a0f;
239
- color: #00ff00;
240
- font-family: 'Courier New', monospace;
241
- font-size: 13px;
242
- padding: 15px;
243
- height: 250px;
244
- overflow-y: auto;
245
- border: 2px solid rgba(138, 43, 226, 0.5);
246
- border-top: 1px solid rgba(138, 43, 226, 0.3);
247
- border-radius: 0 0 10px 10px;
248
  white-space: pre-wrap;
249
  word-wrap: break-word;
250
- box-shadow:
251
- inset 0 0 30px rgba(0, 0, 0, 0.8),
252
- 0 0 20px rgba(138, 43, 226, 0.2);
 
 
 
 
253
  }
254
 
255
- #terminal .note-on {
256
- color: #00ff00;
257
- text-shadow: 0 0 5px #00ff00;
258
  }
259
 
260
- #terminal .note-off {
261
- color: #ff00ff;
262
- text-shadow: 0 0 5px #ff00ff;
263
  }
264
 
265
- #terminal .timestamp {
266
- color: #00ffff;
267
- text-shadow: 0 0 3px #00ffff;
268
  }
269
 
270
- /* Scrollbar styling */
271
  #terminal::-webkit-scrollbar {
272
  width: 10px;
273
  }
274
 
275
  #terminal::-webkit-scrollbar-track {
276
- background: rgba(0, 0, 0, 0.5);
277
  }
278
 
279
  #terminal::-webkit-scrollbar-thumb {
280
- background: linear-gradient(180deg, #ff00ff 0%, #8b00ff 100%);
281
- border-radius: 5px;
282
  }
283
 
284
- #terminal::-webkit-scrollbar-thumb:hover {
285
- background: linear-gradient(180deg, #ff33ff 0%, #aa00ff 100%);
 
 
 
 
 
 
 
 
286
  }
287
- /* =============================================================================
288
- RESPONSIVE DESIGN - MOBILE & TABLET
289
- ============================================================================= */
290
 
291
- /* Tablet (landscape) */
292
- @media (max-width: 1024px) {
293
- .welcome-header h1 {
294
- font-size: 2em;
295
  }
296
-
297
- .key {
298
- width: 35px;
299
- height: 150px;
300
  }
301
-
302
- .key.black {
303
- width: 25px;
304
- height: 95px;
305
- margin-left: -12px;
306
- margin-right: -12px;
307
  }
308
  }
309
 
310
- /* Mobile (portrait and small screens) */
311
- @media (max-width: 768px) {
312
- body {
313
- padding: 10px;
314
  }
315
-
316
- .welcome-header {
317
- padding: 15px;
318
- margin-bottom: 15px;
319
  }
320
-
321
- .welcome-header h1 {
322
- font-size: 1.8em;
323
- letter-spacing: 3px;
 
324
  }
325
-
326
- #mainContainer {
327
- gap: 10px;
328
  }
329
-
330
- #keyboard {
331
- padding: 10px;
332
- overflow-x: auto;
333
- overflow-y: hidden;
334
- -webkit-overflow-scrolling: touch;
 
 
 
335
  }
336
-
 
 
 
 
337
  .key {
338
- width: 28px;
339
- height: 120px;
340
  font-size: 9px;
341
  }
342
-
343
  .key.black {
344
- width: 20px;
345
- height: 75px;
346
- margin-left: -10px;
347
- margin-right: -10px;
348
  }
349
-
350
- .key .shortcut-hint {
351
- font-size: 8px;
352
  }
353
-
354
- .controls {
355
- gap: 8px;
356
- padding: 10px;
357
- font-size: 14px;
358
  }
359
-
360
- button, select {
361
- font-size: 12px;
362
- padding: 6px 10px;
363
  }
364
-
365
- #status {
366
- font-size: 12px;
 
 
 
 
367
  }
368
-
369
- .terminal-header h4 {
370
- font-size: 14px;
 
371
  }
372
-
 
 
 
 
 
 
373
  #terminal {
374
- height: 150px;
375
  font-size: 11px;
376
  }
377
  }
378
 
379
- /* Very small mobile (portrait) */
380
  @media (max-width: 480px) {
381
- .welcome-header h1 {
382
- font-size: 1.5em;
383
- letter-spacing: 2px;
 
 
 
 
 
 
 
 
384
  }
385
-
386
  .key {
387
- width: 22px;
388
- height: 100px;
389
  font-size: 8px;
390
  }
391
-
392
  .key.black {
393
- width: 16px;
394
- height: 65px;
395
- margin-left: -8px;
396
- margin-right: -8px;
397
- }
398
-
399
- .key .shortcut-hint {
400
- font-size: 7px;
401
  }
402
-
403
- .controls {
404
- gap: 6px;
405
- padding: 8px;
406
- font-size: 12px;
407
- }
408
-
409
- button, select {
410
- font-size: 11px;
411
- padding: 5px 8px;
412
- }
413
-
414
- #terminal {
415
- height: 120px;
416
- font-size: 10px;
417
- }
418
- }
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Space+Mono:wght@400;700&display=swap');
2
+
3
+ :root {
4
+ --bg-night: #090511;
5
+ --bg-deep: #150b2e;
6
+ --panel: rgba(18, 10, 38, 0.75);
7
+ --panel-border: rgba(255, 67, 176, 0.4);
8
+ --panel-glow: 0 0 42px rgba(255, 67, 176, 0.24);
9
+ --text-main: #f4deff;
10
+ --text-soft: #c89cff;
11
+ --neon-pink: #ff3fb0;
12
+ --neon-cyan: #3ef4ff;
13
+ --neon-blue: #55a4ff;
14
+ --neon-violet: #8b40ff;
15
+ --neon-green: #8affc7;
16
+ --danger: #ff5f7a;
17
+ --keyboard-white-top: #fef0ff;
18
+ --keyboard-white-bottom: #f1d6ff;
19
+ --keyboard-black-top: #321355;
20
+ --keyboard-black-bottom: #120727;
21
+ --radius-l: 20px;
22
+ --radius-m: 14px;
23
+ --radius-s: 10px;
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ html,
31
+ body {
32
+ width: 100%;
33
+ max-width: 100%;
34
+ overflow-x: clip;
35
+ }
36
 
37
+ body {
 
 
 
38
  margin: 0;
 
 
39
  min-height: 100vh;
40
+ position: relative;
41
+ overflow-x: clip;
42
+ color: var(--text-main);
43
+ font-family: 'Space Mono', monospace;
44
+ background:
45
+ radial-gradient(1000px 500px at 10% -5%, rgba(139, 64, 255, 0.35), transparent 60%),
46
+ radial-gradient(800px 480px at 100% 0%, rgba(62, 244, 255, 0.2), transparent 60%),
47
+ linear-gradient(175deg, var(--bg-night), var(--bg-deep));
48
+ }
49
+
50
+ body::before,
51
+ body::after {
52
+ content: '';
53
+ position: fixed;
54
+ inset: 0;
55
+ pointer-events: none;
56
+ z-index: 0;
57
+ }
58
+
59
+ body::before {
60
+ background:
61
+ radial-gradient(circle at 50% 12%, rgba(255, 112, 190, 0.22) 0 18%, transparent 42%),
62
+ repeating-linear-gradient(
63
+ 90deg,
64
+ rgba(62, 244, 255, 0.06) 0 2px,
65
+ transparent 2px 90px
66
+ );
67
+ opacity: 0.9;
68
+ animation: skyPulse 7s ease-in-out infinite alternate;
69
+ }
70
+
71
+ body::after {
72
+ top: 52%;
73
+ height: 62%;
74
+ background:
75
+ linear-gradient(180deg, rgba(139, 64, 255, 0.08), rgba(9, 5, 17, 0.8)),
76
+ repeating-linear-gradient(
77
+ 0deg,
78
+ rgba(62, 244, 255, 0.15) 0 2px,
79
+ transparent 2px 28px
80
+ ),
81
+ repeating-linear-gradient(
82
+ 90deg,
83
+ rgba(255, 63, 176, 0.18) 0 1px,
84
+ transparent 1px 62px
85
+ );
86
+ transform-origin: top center;
87
+ transform: perspective(650px) rotateX(62deg);
88
+ animation: gridRush 9s linear infinite;
89
+ }
90
+
91
+ .app-shell {
92
+ max-width: 1360px;
93
+ margin: 0 auto;
94
+ padding: 20px 18px 30px;
95
+ position: relative;
96
+ z-index: 1;
97
+ overflow-x: clip;
98
+ }
99
+
100
+ .app-shell::before {
101
+ content: '';
102
+ position: absolute;
103
+ left: 0;
104
+ right: 0;
105
+ top: 118px;
106
+ height: 120px;
107
+ background:
108
+ radial-gradient(closest-side, rgba(62, 244, 255, 0.42), transparent 72%),
109
+ radial-gradient(closest-side, rgba(255, 63, 176, 0.36), transparent 76%);
110
+ filter: blur(22px);
111
+ opacity: 0.6;
112
+ animation: waveDrift 6.5s ease-in-out infinite;
113
+ transform: scaleX(1.15);
114
+ pointer-events: none;
115
  }
116
 
 
117
  .welcome-header {
118
  text-align: center;
119
+ margin-bottom: 18px;
120
+ position: relative;
 
121
  }
122
 
123
+ .welcome-header h1 {
124
+ font-size: 3.2rem;
125
+ font-weight: 900;
126
+ margin: 0 0 12px 0;
127
+ letter-spacing: 3px;
128
+ font-family: 'Orbitron', monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  }
130
 
131
+ .welcome-header .synth {
132
+ color: var(--neon-cyan);
133
+ text-shadow: 0 0 20px rgba(62, 244, 255, 0.8), 0 0 40px rgba(62, 244, 255, 0.5);
 
 
 
134
  }
135
 
136
+ .welcome-header .ia {
137
+ color: #da00ff;
138
+ text-shadow: 0 0 20px rgba(218, 0, 255, 0.8), 0 0 40px rgba(218, 0, 255, 0.5);
139
+ font-style: italic;
140
+ font-weight: 700;
141
+ }
142
+
143
+ .welcome-header::after {
144
+ content: '';
145
+ position: absolute;
146
+ left: 50%;
147
+ bottom: -14px;
148
+ width: min(640px, 88%);
149
+ height: 10px;
150
+ transform: translateX(-50%);
151
+ background:
152
+ radial-gradient(circle at 10% 50%, rgba(62, 244, 255, 0.7), transparent 35%),
153
+ radial-gradient(circle at 50% 50%, rgba(255, 63, 176, 0.65), transparent 34%),
154
+ radial-gradient(circle at 90% 50%, rgba(139, 64, 255, 0.7), transparent 35%);
155
+ filter: blur(8px);
156
+ border-radius: 999px;
157
+ animation: waveDrift 5.8s ease-in-out infinite;
158
+ }
159
+
160
+ .eyebrow {
161
+ margin: 0 0 8px;
162
+ text-transform: uppercase;
163
+ letter-spacing: 0.24em;
164
+ font-size: 11px;
165
+ color: var(--neon-cyan);
166
+ text-shadow: 0 0 12px rgba(62, 244, 255, 0.7);
167
+ }
168
+
169
+ .logo-wrap {
170
+ width: min(700px, 96%);
171
+ margin: 0 auto 2px;
172
+ padding: 0;
173
+ border: 0;
174
+ background: none;
175
+ box-shadow: none;
176
+ }
177
+
178
+ .logo-image {
179
+ display: block;
180
  width: 100%;
181
+ max-height: 186px;
182
+ object-fit: contain;
183
+ filter:
184
+ drop-shadow(0 0 8px rgba(255, 63, 176, 0.72))
185
+ drop-shadow(0 0 26px rgba(62, 244, 255, 0.52))
186
+ drop-shadow(0 0 50px rgba(139, 64, 255, 0.42));
187
+ animation: logoFloat 4.2s ease-in-out infinite;
188
+ }
189
+
190
+ .welcome-text {
191
+ max-width: 900px;
192
+ margin: 20px auto;
193
+ padding: 0 20px;
194
+ text-align: left;
195
  }
196
 
197
+ .welcome-intro {
198
+ font-size: clamp(1rem, 2.5vw, 1.15rem);
199
+ color: var(--text-main);
200
+ text-align: center;
201
+ margin-bottom: 24px;
202
+ line-height: 1.6;
203
+ }
204
+
205
+ .features-guide {
206
+ background: rgba(0, 0, 0, 0.3);
207
+ border: 1px solid rgba(255, 63, 176, 0.2);
208
+ border-radius: 12px;
209
+ padding: 20px 24px;
210
+ margin-top: 16px;
211
+ }
212
+
213
+ .features-guide h3 {
214
+ margin: 0 0 14px 0;
215
+ color: var(--neon-cyan);
216
+ font-size: 1.2rem;
217
+ text-transform: uppercase;
218
+ letter-spacing: 0.08em;
219
+ text-shadow: 0 0 10px rgba(62, 244, 255, 0.6);
220
+ }
221
+
222
+ .features-list {
223
+ margin: 0;
224
+ padding: 0 0 0 20px;
225
+ list-style: none;
226
+ }
227
+
228
+ .features-list li {
229
+ margin-bottom: 10px;
230
+ color: var(--text-soft);
231
+ font-size: clamp(0.88rem, 2vw, 0.98rem);
232
+ line-height: 1.5;
233
+ position: relative;
234
+ }
235
+
236
+ .features-list li::before {
237
+ content: '▸';
238
+ position: absolute;
239
+ left: -20px;
240
+ color: var(--neon-magenta);
241
+ text-shadow: 0 0 8px rgba(255, 63, 176, 0.7);
242
+ }
243
+
244
+ .features-list li strong {
245
+ color: var(--neon-magenta);
246
+ font-weight: 600;
247
+ text-shadow: 0 0 6px rgba(255, 63, 176, 0.4);
248
+ }
249
+
250
+ .sub-list {
251
+ margin: 8px 0 0 20px;
252
+ padding: 0 0 0 20px;
253
+ list-style: none;
254
+ color: var(--text-soft);
255
+ font-size: 0.95em;
256
+ }
257
+
258
+ .sub-list li {
259
+ margin-bottom: 5px;
260
+ padding-left: 0;
261
+ }
262
+
263
+ .sub-list li::before {
264
+ content: '○';
265
+ position: absolute;
266
+ left: -20px;
267
+ color: var(--neon-cyan);
268
+ text-shadow: 0 0 6px rgba(62, 244, 255, 0.5);
269
+ font-weight: bold;
270
+ }
271
+
272
+ .sub-list li em {
273
+ color: var(--neon-cyan);
274
+ font-style: italic;
275
+ text-shadow: 0 0 4px rgba(62, 244, 255, 0.3);
276
+ }
277
+
278
+ .subtitle {
279
+ margin: 4px 0 0;
280
+ color: var(--text-soft);
281
+ font-size: clamp(0.92rem, 2.2vw, 1.04rem);
282
+ }
283
+
284
+ #mainContainer {
285
+ display: grid;
286
+ grid-template-columns: minmax(0, 1fr);
287
+ gap: 20px;
288
+ }
289
+
290
+ .card {
291
+ background: var(--panel);
292
+ border: 1px solid var(--panel-border);
293
+ border-radius: var(--radius-l);
294
+ box-shadow:
295
+ var(--panel-glow),
296
+ inset 0 0 20px rgba(139, 64, 255, 0.17),
297
+ 0 0 0 1px rgba(62, 244, 255, 0.12);
298
+ backdrop-filter: blur(6px);
299
+ }
300
+
301
+ .keyboard-section {
302
+ padding: 18px;
303
+ }
304
+
305
+ #keyboard {
306
+ display: flex;
307
+ justify-content: center;
308
+ width: 100%;
309
+ padding: 20px 14px;
310
+ border-radius: 16px;
311
+ border: 1px solid rgba(62, 244, 255, 0.38);
312
+ background:
313
+ linear-gradient(180deg, rgba(21, 10, 43, 0.92), rgba(10, 4, 23, 0.92)),
314
+ radial-gradient(circle at 50% 0, rgba(62, 244, 255, 0.14), transparent 60%);
315
+ box-shadow:
316
+ inset 0 0 20px rgba(62, 244, 255, 0.16),
317
+ 0 0 22px rgba(62, 244, 255, 0.2),
318
+ 0 0 34px rgba(255, 63, 176, 0.18);
319
  user-select: none;
320
  touch-action: none;
321
+ overflow-x: auto;
322
+ }
323
+
324
+ .key {
325
+ width: 44px;
326
+ height: 190px;
327
+ margin: 0 1px;
328
+ border: 1px solid rgba(255, 63, 176, 0.45);
329
+ border-bottom-width: 4px;
330
+ border-radius: 0 0 11px 11px;
331
+ background: linear-gradient(180deg, var(--keyboard-white-top), var(--keyboard-white-bottom));
332
+ position: relative;
333
+ display: flex;
334
+ align-items: flex-end;
335
+ justify-content: center;
 
 
 
 
336
  cursor: pointer;
337
+ color: #51176e;
338
+ font-size: 11px;
339
+ font-weight: 700;
340
+ transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
 
 
 
341
  }
342
 
343
+ .key:hover {
344
+ transform: translateY(1px);
345
+ box-shadow: inset 0 -5px 12px rgba(255, 63, 176, 0.16);
 
 
 
 
346
  }
347
 
348
  .key.black {
349
+ width: 30px;
350
+ height: 118px;
351
+ margin-left: -15px;
352
+ margin-right: -15px;
353
+ z-index: 3;
354
+ border-radius: 0 0 9px 9px;
355
+ border-color: rgba(62, 244, 255, 0.55);
356
+ border-bottom-width: 3px;
357
+ background: linear-gradient(180deg, var(--keyboard-black-top), var(--keyboard-black-bottom));
358
+ color: #bdf6ff;
359
+ box-shadow: 0 6px 10px rgba(0, 0, 0, 0.55);
360
+ }
361
+
362
+ .key.black:hover {
363
+ box-shadow: 0 0 16px rgba(62, 244, 255, 0.4);
364
+ }
365
+
366
+ .key .shortcut-hint {
367
+ display: block;
368
+ opacity: 0;
369
+ font-size: 10px;
370
+ color: var(--neon-violet);
371
+ text-shadow: 0 0 9px rgba(139, 64, 255, 0.8);
372
+ transition: opacity 160ms ease;
373
  }
374
 
375
  .key.black .shortcut-hint {
376
+ color: var(--neon-cyan);
377
+ text-shadow: 0 0 9px rgba(62, 244, 255, 0.95);
378
  }
379
 
380
  .shortcuts-visible .key .shortcut-hint {
381
  opacity: 1;
382
  }
383
 
384
+ .keyboard-toggle-row {
385
+ display: flex;
386
+ justify-content: space-between;
387
+ align-items: center;
388
+ margin-top: 10px;
389
+ margin-bottom: 2px;
390
+ gap: 12px;
391
+ }
392
+
393
+ .tooltip-display-area {
394
+ flex: 1;
395
+ padding: 8px 12px;
396
+ background: rgba(62, 244, 255, 0.1);
397
+ border: 1px solid rgba(62, 244, 255, 0.4);
398
+ border-radius: var(--radius-s);
399
+ color: var(--neon-cyan);
400
+ font-size: 12px;
401
+ min-height: 20px;
402
+ display: flex;
403
+ align-items: center;
404
+ text-shadow: 0 0 8px rgba(62, 244, 255, 0.5);
405
+ box-shadow: 0 0 12px rgba(62, 244, 255, 0.15);
406
+ }
407
+
408
+ .keyboard-toggle-pill {
409
  position: relative;
410
+ display: inline-flex;
411
+ align-items: center;
412
+ gap: 10px;
413
+ padding: 8px 12px;
414
+ border-radius: 999px;
415
+ border: 1px solid rgba(62, 244, 255, 0.36);
416
+ background: linear-gradient(160deg, rgba(22, 10, 46, 0.9), rgba(10, 6, 26, 0.92));
417
+ box-shadow:
418
+ 0 0 16px rgba(62, 244, 255, 0.16),
419
+ inset 0 0 12px rgba(255, 63, 176, 0.12);
420
+ cursor: pointer;
421
+ user-select: none;
422
  }
423
 
424
+ .keyboard-toggle-pill input[type='checkbox'] {
425
+ position: absolute;
426
+ opacity: 0;
427
+ width: 0;
428
+ height: 0;
429
+ margin: 0;
430
  }
431
 
432
+ .toggle-track {
433
+ position: relative;
434
+ width: 44px;
435
+ height: 24px;
436
+ border-radius: 999px;
437
+ border: 1px solid rgba(255, 63, 176, 0.5);
438
+ background: rgba(10, 8, 25, 0.95);
439
+ box-shadow: inset 0 0 8px rgba(255, 63, 176, 0.22);
440
+ transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
441
+ }
442
+
443
+ .toggle-track::before {
444
+ content: '';
445
+ position: absolute;
446
+ top: 2px;
447
+ left: 2px;
448
+ width: 18px;
449
+ height: 18px;
450
+ border-radius: 50%;
451
+ background: linear-gradient(180deg, #ff77d0, #ff3fb0);
452
+ box-shadow: 0 0 10px rgba(255, 63, 176, 0.6);
453
+ transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease;
454
  }
455
 
456
+ .keyboard-toggle-pill input[type='checkbox']:checked + .toggle-track {
457
+ border-color: rgba(62, 244, 255, 0.8);
458
+ background: linear-gradient(90deg, rgba(255, 63, 176, 0.32), rgba(62, 244, 255, 0.38));
459
+ box-shadow:
460
+ inset 0 0 10px rgba(62, 244, 255, 0.3),
461
+ 0 0 14px rgba(62, 244, 255, 0.25);
462
+ }
463
+
464
+ .keyboard-toggle-pill input[type='checkbox']:checked + .toggle-track::before {
465
+ transform: translateX(20px);
466
+ background: linear-gradient(180deg, #9ffcff, #3ef4ff);
467
+ box-shadow: 0 0 11px rgba(62, 244, 255, 0.8);
468
+ }
469
+
470
+ .keyboard-toggle-pill input[type='checkbox']:focus-visible + .toggle-track {
471
+ outline: 2px solid rgba(62, 244, 255, 0.75);
472
+ outline-offset: 2px;
473
+ }
474
+
475
+ .toggle-text {
476
+ color: #d9e3ff;
477
+ font-size: 12px;
478
+ font-weight: 700;
479
+ letter-spacing: 0.02em;
480
+ text-shadow: 0 0 8px rgba(62, 244, 255, 0.25);
481
+ }
482
+
483
+ .controls {
484
+ margin-top: 16px;
485
+ padding: 16px;
486
+ border-radius: var(--radius-m);
487
+ border: 1px solid rgba(62, 244, 255, 0.35);
488
+ background: rgba(11, 6, 24, 0.72);
489
+ }
490
+
491
+ .control-grid {
492
+ display: grid;
493
+ grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
494
+ gap: 10px;
495
+ }
496
+
497
+ .control-item {
498
  display: flex;
499
+ flex-direction: column;
500
+ gap: 7px;
501
+ padding: 10px;
502
+ border-radius: var(--radius-s);
503
+ border: 1px solid rgba(255, 63, 176, 0.32);
504
+ background: linear-gradient(160deg, rgba(31, 15, 62, 0.88), rgba(16, 8, 34, 0.9));
505
+ color: #f5d4ff;
506
+ font-size: 12px;
507
+ font-weight: 700;
508
+ letter-spacing: 0.01em;
509
+ position: relative;
510
+ }
511
+
512
+ .control-item-toggle {
513
+ flex-direction: row;
514
  align-items: center;
515
+ justify-content: flex-start;
516
+ gap: 10px;
517
+ font-size: 13px;
518
+ }
519
+
520
+ .btn-tooltip-wrapper {
521
+ position: relative;
522
+ display: inline-block;
523
+ }
524
+
525
+ .btn-tooltip {
526
+ display: none;
527
+ position: absolute;
528
+ bottom: 100%;
529
+ left: 50%;
530
+ transform: translateX(-50%);
531
+ margin-bottom: 6px;
532
+ padding: 8px 12px;
533
+ background: rgba(62, 244, 255, 0.15);
534
+ border: 1px solid rgba(62, 244, 255, 0.6);
535
+ border-radius: var(--radius-s);
536
+ color: var(--neon-cyan);
537
+ font-size: 12px;
538
+ white-space: nowrap;
539
+ z-index: 100;
540
+ box-shadow: 0 0 20px rgba(62, 244, 255, 0.3);
541
+ text-shadow: 0 0 8px rgba(62, 244, 255, 0.5);
542
+ pointer-events: none;
543
+ }
544
+
545
+ .btn-tooltip-wrapper:hover .btn-tooltip {
546
+ display: block;
547
+ animation: tooltipAppear 0.2s ease-out;
548
+ }
549
+
550
+ .runtime-tooltip.show {
551
+ display: block;
552
+ animation: tooltipAppear 0.2s ease-out;
553
+ }
554
+
555
+ @keyframes tooltipAppear {
556
+ from {
557
+ opacity: 0;
558
+ transform: translateX(-50%) translateY(4px);
559
+ }
560
+ to {
561
+ opacity: 1;
562
+ transform: translateX(-50%) translateY(0);
563
+ }
564
  }
565
 
566
  select,
567
+ button {
568
+ font-family: 'Space Mono', monospace;
569
+ }
570
+
571
+ select {
572
+ width: 100%;
573
+ padding: 8px 10px;
574
+ border: 1px solid rgba(62, 244, 255, 0.42);
575
+ border-radius: 9px;
576
+ background: rgba(10, 6, 22, 0.92);
577
+ color: var(--neon-cyan);
578
+ font-size: 13px;
579
+ }
580
+
581
+ select:focus,
582
+ button:focus,
583
+ input[type='checkbox']:focus {
584
+ outline: 2px solid rgba(62, 244, 255, 0.6);
585
+ outline-offset: 1px;
586
+ }
587
+
588
+ input[type='checkbox'] {
589
+ -webkit-appearance: none;
590
+ appearance: none;
591
+ width: 20px;
592
+ height: 20px;
593
  border-radius: 5px;
594
+ border: 2px solid var(--neon-pink);
595
+ background: rgba(11, 6, 24, 0.95);
596
+ position: relative;
597
  cursor: pointer;
598
+ box-shadow: inset 0 0 8px rgba(255, 63, 176, 0.2);
 
599
  }
600
 
601
+ input[type='checkbox']::after {
602
+ content: '';
603
+ position: absolute;
604
+ left: 5px;
605
+ top: 1px;
606
+ width: 5px;
607
+ height: 10px;
608
+ border: solid var(--neon-cyan);
609
+ border-width: 0 2px 2px 0;
610
+ transform: rotate(45deg) scale(0);
611
+ transition: transform 90ms ease;
612
+ filter: drop-shadow(0 0 5px rgba(62, 244, 255, 0.8));
613
  }
614
 
615
+ input[type='checkbox']:checked {
616
+ background: rgba(255, 63, 176, 0.26);
617
+ box-shadow:
618
+ inset 0 0 10px rgba(255, 63, 176, 0.6),
619
+ 0 0 10px rgba(255, 63, 176, 0.45);
620
  }
621
 
622
+ input[type='checkbox']:checked::after {
623
+ transform: rotate(45deg) scale(1);
624
+ }
625
+
626
+ .action-row {
627
+ margin-top: 14px;
628
+ display: flex;
629
+ flex-wrap: wrap;
630
+ align-items: center;
631
+ gap: 8px;
632
  }
633
 
634
+ .btn {
635
+ border: 0;
636
+ border-radius: 10px;
637
+ padding: 9px 14px;
638
+ font-size: 13px;
639
+ font-weight: 700;
640
+ color: #fff;
641
  cursor: pointer;
642
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
643
+ text-shadow: 0 0 8px rgba(0, 0, 0, 0.45);
 
644
  }
645
 
646
+ .btn:hover:not(:disabled) {
647
+ transform: translateY(-1px);
648
+ filter: saturate(1.1) brightness(1.08);
649
+ }
650
+
651
+ .btn:disabled {
652
+ opacity: 0.42;
653
+ cursor: not-allowed;
654
+ }
655
+
656
+ .btn-primary {
657
+ background: linear-gradient(180deg, #ff4fbd, #d42f8e);
658
+ box-shadow: 0 0 18px rgba(255, 79, 189, 0.45);
659
+ }
660
+
661
+ .btn-secondary {
662
+ background: linear-gradient(180deg, #5464ff, #383fbc);
663
+ box-shadow: 0 0 15px rgba(84, 100, 255, 0.38);
664
+ }
665
+
666
+ .btn-game {
667
+ background: linear-gradient(180deg, #43d3ff, #2f9bff);
668
+ color: #041322;
669
+ text-shadow: none;
670
+ box-shadow: 0 0 18px rgba(67, 211, 255, 0.45);
671
+ }
672
+
673
+ .btn-danger {
674
+ background: linear-gradient(180deg, #ff6688, #e9385f);
675
+ box-shadow: 0 0 18px rgba(255, 102, 136, 0.42);
676
+ }
677
+
678
+ #status {
679
+ margin-left: auto;
680
+ padding: 8px 12px;
681
+ border-radius: 999px;
682
+ border: 1px solid rgba(62, 244, 255, 0.6);
683
+ background: rgba(10, 9, 28, 0.8);
684
+ color: var(--neon-green);
685
+ text-shadow: 0 0 8px rgba(138, 255, 199, 0.75);
686
+ font-size: 12px;
687
+ font-weight: 700;
688
+ }
689
+
690
+ .monitor-section {
691
+ overflow: hidden;
692
  }
693
 
694
+ .terminal-header {
695
+ display: flex;
696
+ justify-content: space-between;
697
+ align-items: center;
698
+ gap: 12px;
699
+ padding: 12px 14px;
700
+ border-bottom: 1px solid rgba(62, 244, 255, 0.28);
701
+ background: linear-gradient(180deg, rgba(33, 16, 70, 0.85), rgba(20, 9, 40, 0.75));
 
 
702
  }
703
 
704
  .terminal-header h4 {
705
  margin: 0;
706
+ font-family: 'Orbitron', sans-serif;
707
+ font-size: 14px;
708
+ letter-spacing: 0.08em;
709
+ color: var(--neon-pink);
710
+ text-shadow: 0 0 12px rgba(255, 63, 176, 0.8);
711
+ }
712
+
713
+ #terminal {
714
+ margin: 0;
715
+ min-height: 220px;
716
+ max-height: 320px;
717
+ overflow-y: auto;
718
+ padding: 14px;
 
 
 
 
 
 
 
 
 
719
  white-space: pre-wrap;
720
  word-wrap: break-word;
721
+ background:
722
+ radial-gradient(700px 220px at 100% 0, rgba(62, 244, 255, 0.18), transparent 62%),
723
+ #07040f;
724
+ color: #a5ffd2;
725
+ font-family: 'Space Mono', monospace;
726
+ font-size: 12px;
727
+ line-height: 1.35;
728
  }
729
 
730
+ #terminal .note-on {
731
+ color: #8affc7;
732
+ text-shadow: 0 0 8px rgba(138, 255, 199, 0.75);
733
  }
734
 
735
+ #terminal .note-off {
736
+ color: #8dc5ff;
737
+ text-shadow: 0 0 8px rgba(141, 197, 255, 0.7);
738
  }
739
 
740
+ #terminal .timestamp {
741
+ color: #ffdd84;
742
+ text-shadow: 0 0 8px rgba(255, 221, 132, 0.7);
743
  }
744
 
 
745
  #terminal::-webkit-scrollbar {
746
  width: 10px;
747
  }
748
 
749
  #terminal::-webkit-scrollbar-track {
750
+ background: rgba(8, 5, 18, 0.7);
751
  }
752
 
753
  #terminal::-webkit-scrollbar-thumb {
754
+ border-radius: 10px;
755
+ background: linear-gradient(180deg, #ff3fb0, #8b40ff);
756
  }
757
 
758
+ @keyframes logoFloat {
759
+ 0% {
760
+ transform: translateY(0) scale(1);
761
+ }
762
+ 50% {
763
+ transform: translateY(-4px) scale(1.01);
764
+ }
765
+ 100% {
766
+ transform: translateY(0) scale(1);
767
+ }
768
  }
 
 
 
769
 
770
+ @keyframes waveDrift {
771
+ 0% {
772
+ transform: translateX(-2%) translateY(0);
 
773
  }
774
+ 50% {
775
+ transform: translateX(2%) translateY(6px);
 
 
776
  }
777
+ 100% {
778
+ transform: translateX(-2%) translateY(0);
 
 
 
 
779
  }
780
  }
781
 
782
+ @keyframes skyPulse {
783
+ 0% {
784
+ opacity: 0.82;
785
+ filter: hue-rotate(0deg);
786
  }
787
+ 100% {
788
+ opacity: 1;
789
+ filter: hue-rotate(12deg);
 
790
  }
791
+ }
792
+
793
+ @keyframes gridRush {
794
+ 0% {
795
+ background-position: 0 0, 0 0, 0 0;
796
  }
797
+ 100% {
798
+ background-position: 0 0, 0 220px, 62px 0;
 
799
  }
800
+ }
801
+
802
+ @media (max-width: 980px) {
803
+ .app-shell {
804
+ padding: 16px 12px 24px;
805
+ }
806
+
807
+ .keyboard-section {
808
+ padding: 12px;
809
  }
810
+
811
+ .controls {
812
+ padding: 12px;
813
+ }
814
+
815
  .key {
816
+ width: 35px;
817
+ height: 158px;
818
  font-size: 9px;
819
  }
820
+
821
  .key.black {
822
+ width: 24px;
823
+ height: 98px;
824
+ margin-left: -12px;
825
+ margin-right: -12px;
826
  }
827
+
828
+ .control-grid {
829
+ grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
830
  }
831
+ }
832
+
833
+ @media (max-width: 640px) {
834
+ .welcome-header {
835
+ margin-bottom: 12px;
836
  }
837
+
838
+ .logo-wrap {
839
+ width: min(560px, 98%);
840
+ padding: 0;
841
  }
842
+
843
+ .logo-image {
844
+ max-height: 132px;
845
+ }
846
+
847
+ .control-grid {
848
+ grid-template-columns: 1fr 1fr;
849
  }
850
+
851
+ .keyboard-toggle-row {
852
+ justify-content: center;
853
+ margin-top: 12px;
854
  }
855
+
856
+ #status {
857
+ margin-left: 0;
858
+ width: 100%;
859
+ text-align: center;
860
+ }
861
+
862
  #terminal {
863
+ min-height: 180px;
864
  font-size: 11px;
865
  }
866
  }
867
 
 
868
  @media (max-width: 480px) {
869
+ .app-shell::before {
870
+ top: 90px;
871
+ height: 80px;
872
+ }
873
+
874
+ .control-grid {
875
+ grid-template-columns: 1fr;
876
+ }
877
+
878
+ #keyboard {
879
+ justify-content: flex-start;
880
  }
881
+
882
  .key {
883
+ width: 27px;
884
+ height: 126px;
885
  font-size: 8px;
886
  }
887
+
888
  .key.black {
889
+ width: 18px;
890
+ height: 76px;
891
+ margin-left: -9px;
892
+ margin-right: -9px;
 
 
 
 
893
  }
894
+ }