Goonjan commited on
Commit
29cf188
·
1 Parent(s): 79731ec

First working version of frontend

Browse files
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ffmpeg for audio I/O, libsndfile for soundfile, git for demucs model download
4
+ RUN apt-get update && \
5
+ apt-get install -y --no-install-recommends ffmpeg libsndfile1 git && \
6
+ rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Install Python deps first so Docker layer cache is reused on code changes
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+ RUN pip install --no-cache-dir fastapi uvicorn python-multipart
14
+
15
+ COPY . .
16
+ RUN pip install --no-cache-dir -e .
17
+
18
+ # Hugging Face Spaces requires port 7860
19
+ EXPOSE 7860
20
+
21
+ CMD ["uvicorn", "keyarrange.api.app:app", "--host", "0.0.0.0", "--port", "7860"]
notes.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ I am done with all the transforms and ran the pipeline. I feel like the output has degraded in quality: I am not able to recognize the part that is after the intro either right now. I removed the note_cap function, and it seemed to have gotten better but still not that good.
pyproject.toml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "keyarrange"
3
+ version = "0.1.0"
4
+ dependencies = []
5
+
6
+ [build-system]
7
+ requires = ["setuptools"]
8
+ build-backend = "setuptools.build_meta"
9
+
10
+ [tool.setuptools.packages.find]
11
+ where = ["src"]
requirements.txt CHANGED
@@ -13,5 +13,10 @@ music21==9.1.0
13
  pretty-midi==0.2.10
14
  setuptools==69.5.1 # Pinned to avoid pkg_resources removal in newer versions
15
 
 
 
 
 
 
16
  # General utilities
17
  numpy==1.24.3
 
13
  pretty-midi==0.2.10
14
  setuptools==69.5.1 # Pinned to avoid pkg_resources removal in newer versions
15
 
16
+ # Frontend and visualization
17
+ fastapi==0.135.1
18
+ uvicorn==0.42.0
19
+ python-multipart==0.0.22
20
+
21
  # General utilities
22
  numpy==1.24.3
src/keyarrange/{audio → api}/__init__.py RENAMED
File without changes
src/keyarrange/api/app.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend — wraps Pipeline and serves MIDI + piano roll."""
2
+ import asyncio
3
+ import logging
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, File, HTTPException, UploadFile
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import FileResponse, HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from keyarrange.pipeline import Pipeline
13
+ from keyarrange.render.piano_roll import render_piano_roll
14
+
15
+ logger = logging.getLogger(__name__)
16
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
17
+
18
+ app = FastAPI(title="KeyArrange")
19
+
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_methods=["*"],
24
+ allow_headers=["*"],
25
+ )
26
+
27
+ UPLOAD_DIR = Path("/tmp/keyarrange/uploads")
28
+ OUTPUT_DIR = Path("/tmp/keyarrange/outputs")
29
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
30
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
31
+
32
+ # Serve frontend
33
+ app.mount("/web", StaticFiles(directory="web"), name="web")
34
+
35
+
36
+ @app.get("/", response_class=HTMLResponse)
37
+ async def root():
38
+ return (Path("web") / "index.html").read_text()
39
+
40
+
41
+ @app.get("/health")
42
+ async def health():
43
+ return {"status": "ok"}
44
+
45
+
46
+ @app.post("/upload")
47
+ async def upload(file: UploadFile = File(...)):
48
+ if not file.filename.lower().endswith((".mp3", ".wav")):
49
+ raise HTTPException(status_code=400, detail="Only MP3 and WAV files are supported.")
50
+
51
+ job_id = Path(file.filename).stem[:40].replace(" ", "_") + "_" + uuid.uuid4().hex[:6]
52
+ upload_path = UPLOAD_DIR / f"{job_id}{Path(file.filename).suffix}"
53
+ upload_path.write_bytes(await file.read())
54
+
55
+ logger.info(f"Job {job_id}: starting pipeline")
56
+
57
+ try:
58
+ loop = asyncio.get_event_loop()
59
+ midi_path = await loop.run_in_executor(None, _run_pipeline, str(upload_path), job_id)
60
+ except Exception as e:
61
+ logger.error(f"Job {job_id} failed: {e}")
62
+ raise HTTPException(status_code=500, detail=f"Pipeline failed: {str(e)}")
63
+
64
+ piano_roll_path = Path(midi_path).parent / "piano_roll.png"
65
+ try:
66
+ await loop.run_in_executor(None, render_piano_roll, midi_path, str(piano_roll_path))
67
+ except Exception as e:
68
+ logger.warning(f"Job {job_id}: piano roll render failed ({e}), continuing without it")
69
+ piano_roll_path = None
70
+
71
+ return {
72
+ "job_id": job_id,
73
+ "midi_url": f"/download/midi/{job_id}",
74
+ "piano_roll_url": f"/download/piano_roll/{job_id}" if piano_roll_path and piano_roll_path.exists() else None,
75
+ }
76
+
77
+
78
+ def _run_pipeline(upload_path: str, job_id: str) -> str:
79
+ """Synchronous pipeline call — run in executor to avoid blocking event loop."""
80
+ output_dir = str(OUTPUT_DIR / job_id)
81
+ pipeline = Pipeline(upload_path, output_dir)
82
+ return pipeline.run()
83
+
84
+
85
+ @app.get("/download/{file_type}/{job_id}")
86
+ async def download(file_type: str, job_id: str):
87
+ if file_type == "midi":
88
+ # Pipeline writes to OUTPUT_DIR/job_id/<song_name>/arranged/arranged.mid
89
+ # Glob for it since song_name is embedded in path
90
+ matches = list((OUTPUT_DIR / job_id).rglob("arranged.mid"))
91
+ if not matches:
92
+ raise HTTPException(status_code=404, detail="MIDI file not found.")
93
+ return FileResponse(str(matches[0]), media_type="audio/midi", filename="keyarrange.mid")
94
+
95
+ elif file_type == "piano_roll":
96
+ matches = list((OUTPUT_DIR / job_id).rglob("piano_roll.png"))
97
+ if not matches:
98
+ raise HTTPException(status_code=404, detail="Piano roll not found.")
99
+ return FileResponse(str(matches[0]), media_type="image/png")
100
+
101
+ else:
102
+ raise HTTPException(status_code=400, detail="file_type must be 'midi' or 'piano_roll'.")
src/keyarrange/pipeline.py CHANGED
@@ -61,9 +61,9 @@ class Pipeline:
61
  quantized_left_notes = quantize_to_beats(left_notes, beat_times)
62
 
63
  logger.info("Applying transformations to Right hand notes...")
64
- right_notes = density_reducer(right_notes, bpm)
65
- right_notes = span_enforcer(right_notes, max_span=12, hand="right")
66
- right_notes = note_cap(right_notes, max_notes=3)
67
 
68
  logger.info("Applying transformations to Left hand notes...")
69
  left_notes = density_reducer(quantized_left_notes, bpm)
 
61
  quantized_left_notes = quantize_to_beats(left_notes, beat_times)
62
 
63
  logger.info("Applying transformations to Right hand notes...")
64
+ # right_notes = density_reducer(right_notes, bpm)
65
+ # right_notes = span_enforcer(right_notes, max_span=12, hand="right")
66
+ # right_notes = note_cap(right_notes, max_notes=3)
67
 
68
  logger.info("Applying transformations to Left hand notes...")
69
  left_notes = density_reducer(quantized_left_notes, bpm)
src/keyarrange/render/piano_roll.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Renders a static piano roll PNG from a two-track arranged MIDI file."""
2
+ import logging
3
+
4
+ import matplotlib
5
+ matplotlib.use("Agg") # non-interactive backend — safe for server use
6
+ import matplotlib.patches as mpatches
7
+ import matplotlib.pyplot as plt
8
+ import pretty_midi
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Pitch range displayed (covers standard piano arrangement output)
13
+ PITCH_LOW = 36 # C2
14
+ PITCH_HIGH = 84 # C6
15
+
16
+ RIGHT_COLOR = "#4a9eff" # blue — right hand (vocals/melody)
17
+ LEFT_COLOR = "#ff6b6b" # red — left hand (bass/harmony)
18
+ BG_COLOR = "#0d0d0d"
19
+ SURFACE = "#141414"
20
+ ACCENT = "#c8f043"
21
+
22
+
23
+ def render_piano_roll(midi_path: str, output_path: str) -> str:
24
+ """Read MIDI at midi_path, write piano roll PNG to output_path, return output_path."""
25
+ pm = pretty_midi.PrettyMIDI(midi_path)
26
+ duration = pm.get_end_time()
27
+ if duration == 0:
28
+ raise ValueError("MIDI file has no notes.")
29
+
30
+ fig_w, fig_h = 14, 5
31
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h))
32
+ fig.patch.set_facecolor(BG_COLOR)
33
+ ax.set_facecolor(SURFACE)
34
+
35
+ pitch_range = PITCH_HIGH - PITCH_LOW
36
+ note_height = 0.75 # in pitch units
37
+
38
+ # --- time grid lines every 2 seconds ---
39
+ for t in range(0, int(duration) + 1, 2):
40
+ ax.axvline(x=t, color="white", alpha=0.08, linewidth=0.5, zorder=1)
41
+
42
+ # --- middle C divider (pitch 60) ---
43
+ ax.axhline(y=60, color="white", alpha=0.25, linewidth=0.8, linestyle="--", zorder=2)
44
+ ax.text(duration * 0.01, 60.3, "C4 · middle C",
45
+ color="white", alpha=0.35, fontsize=7, fontfamily="monospace")
46
+
47
+ # --- draw notes per track ---
48
+ colors = [RIGHT_COLOR, LEFT_COLOR]
49
+ labels = ["Right hand (melody)", "Left hand (harmony)"]
50
+
51
+ for track_idx, instrument in enumerate(pm.instruments[:2]):
52
+ color = colors[track_idx] if track_idx < len(colors) else "#aaaaaa"
53
+ for note in instrument.notes:
54
+ if not (PITCH_LOW <= note.pitch <= PITCH_HIGH):
55
+ continue
56
+ rect = mpatches.FancyBboxPatch(
57
+ (note.start, note.pitch - note_height / 2),
58
+ note.end - note.start,
59
+ note_height,
60
+ boxstyle="round,pad=0.02",
61
+ linewidth=0,
62
+ facecolor=color,
63
+ alpha=0.85,
64
+ zorder=3,
65
+ )
66
+ ax.add_patch(rect)
67
+
68
+ # --- keyboard strip on left edge ---
69
+ _draw_keyboard_strip(ax, duration)
70
+
71
+ # --- axes ---
72
+ ax.set_xlim(0, duration)
73
+ ax.set_ylim(PITCH_LOW - 1, PITCH_HIGH + 1)
74
+ ax.set_xlabel("Time (seconds)", color="#666", fontsize=9, labelpad=6)
75
+ ax.tick_params(colors="#444")
76
+ ax.spines[:].set_visible(False)
77
+
78
+ # y-axis: show octave labels only
79
+ octave_ticks = [p for p in range(PITCH_LOW, PITCH_HIGH + 1) if p % 12 == 0]
80
+ ax.set_yticks(octave_ticks)
81
+ ax.set_yticklabels([pretty_midi.note_number_to_name(p) for p in octave_ticks],
82
+ color="#555", fontsize=8)
83
+
84
+ # --- legend ---
85
+ legend_patches = [
86
+ mpatches.Patch(color=RIGHT_COLOR, label=labels[0]),
87
+ mpatches.Patch(color=LEFT_COLOR, label=labels[1]),
88
+ ]
89
+ ax.legend(handles=legend_patches, loc="upper right",
90
+ facecolor="#1a1a1a", edgecolor="#333",
91
+ labelcolor="white", fontsize=9, framealpha=0.9)
92
+
93
+ plt.tight_layout(pad=0.5)
94
+ fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor=BG_COLOR)
95
+ plt.close(fig)
96
+ logger.info(f"Piano roll saved: {output_path}")
97
+ return output_path
98
+
99
+
100
+ def _draw_keyboard_strip(ax, duration):
101
+ """Draws a minimal keyboard indicator along the left edge of the plot."""
102
+ strip_width = duration * 0.012 # ~1.2% of total width
103
+ x0 = -strip_width * 1.1
104
+
105
+ # Black key pitches within an octave (semitone offsets: 1,3,6,8,10)
106
+ black_offsets = {1, 3, 6, 8, 10}
107
+
108
+ for pitch in range(PITCH_LOW, PITCH_HIGH + 1):
109
+ is_black = (pitch % 12) in black_offsets
110
+ color = "#111" if is_black else "#ddd"
111
+ rect = mpatches.Rectangle(
112
+ (x0, pitch - 0.45),
113
+ strip_width * (0.6 if is_black else 1.0),
114
+ 0.88,
115
+ linewidth=0.3,
116
+ edgecolor="#333",
117
+ facecolor=color,
118
+ zorder=4,
119
+ clip_on=False,
120
+ )
121
+ ax.add_patch(rect)
src/keyarrange/transcription/__init__.py ADDED
File without changes
web/index.html ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>KeyArrange</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap');
9
+
10
+ :root {
11
+ --bg: #0d0d0d;
12
+ --surface: #141414;
13
+ --surface2: #1a1a1a;
14
+ --border: #242424;
15
+ --accent: #c8f043;
16
+ --accent2: #f0c843;
17
+ --accent3: #43c8f0;
18
+ --text: #e8e8e0;
19
+ --text-dim: #666;
20
+ --text-mid: #999;
21
+ }
22
+
23
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24
+
25
+ body {
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: 'DM Sans', sans-serif;
29
+ font-weight: 300;
30
+ min-height: 100vh;
31
+ padding: 48px 24px 80px;
32
+ }
33
+
34
+ .container { max-width: 760px; margin: 0 auto; }
35
+
36
+ header {
37
+ border-bottom: 1px solid var(--border);
38
+ padding-bottom: 32px;
39
+ margin-bottom: 40px;
40
+ }
41
+
42
+ .eyebrow {
43
+ font-family: 'DM Mono', monospace;
44
+ font-size: 11px;
45
+ letter-spacing: 0.2em;
46
+ color: var(--accent);
47
+ text-transform: uppercase;
48
+ margin-bottom: 12px;
49
+ }
50
+
51
+ h1 {
52
+ font-family: 'DM Serif Display', serif;
53
+ font-size: clamp(28px, 5vw, 44px);
54
+ line-height: 1.1;
55
+ color: var(--text);
56
+ margin-bottom: 16px;
57
+ }
58
+
59
+ h1 em {
60
+ font-style: italic;
61
+ color: var(--accent);
62
+ }
63
+
64
+ .subtitle {
65
+ font-size: 14px;
66
+ color: var(--text-mid);
67
+ max-width: 480px;
68
+ line-height: 1.7;
69
+ }
70
+
71
+ /* Upload section */
72
+ .upload-section {
73
+ margin-bottom: 32px;
74
+ }
75
+
76
+ .drop-zone {
77
+ border: 1px dashed var(--border);
78
+ border-radius: 3px;
79
+ padding: 40px 32px;
80
+ text-align: center;
81
+ background: var(--surface);
82
+ cursor: pointer;
83
+ transition: all 0.2s;
84
+ position: relative;
85
+ }
86
+
87
+ .drop-zone:hover, .drop-zone.drag-over {
88
+ border-color: var(--accent);
89
+ background: rgba(200, 240, 67, 0.03);
90
+ }
91
+
92
+ .drop-zone input[type="file"] {
93
+ position: absolute;
94
+ inset: 0;
95
+ opacity: 0;
96
+ cursor: pointer;
97
+ width: 100%;
98
+ height: 100%;
99
+ }
100
+
101
+ .drop-icon {
102
+ font-family: 'DM Mono', monospace;
103
+ font-size: 28px;
104
+ margin-bottom: 12px;
105
+ color: var(--text-dim);
106
+ }
107
+
108
+ .drop-label {
109
+ font-size: 14px;
110
+ color: var(--text-mid);
111
+ margin-bottom: 6px;
112
+ }
113
+
114
+ .drop-sub {
115
+ font-family: 'DM Mono', monospace;
116
+ font-size: 11px;
117
+ color: var(--text-dim);
118
+ letter-spacing: 0.1em;
119
+ text-transform: uppercase;
120
+ }
121
+
122
+ .file-selected {
123
+ margin-top: 14px;
124
+ display: none;
125
+ align-items: center;
126
+ gap: 10px;
127
+ padding: 12px 16px;
128
+ background: rgba(200, 240, 67, 0.06);
129
+ border: 1px solid rgba(200, 240, 67, 0.2);
130
+ border-radius: 3px;
131
+ }
132
+
133
+ .file-selected.visible { display: flex; }
134
+
135
+ .file-name {
136
+ font-family: 'DM Mono', monospace;
137
+ font-size: 12px;
138
+ color: var(--accent);
139
+ flex: 1;
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ }
144
+
145
+ .file-clear {
146
+ background: none;
147
+ border: none;
148
+ color: var(--text-dim);
149
+ cursor: pointer;
150
+ font-size: 16px;
151
+ line-height: 1;
152
+ padding: 0 4px;
153
+ }
154
+
155
+ .file-clear:hover { color: var(--text); }
156
+
157
+ /* Arrange button */
158
+ .btn-arrange {
159
+ width: 100%;
160
+ padding: 16px;
161
+ background: var(--accent);
162
+ color: #0d0d0d;
163
+ border: none;
164
+ border-radius: 3px;
165
+ font-family: 'DM Mono', monospace;
166
+ font-size: 13px;
167
+ font-weight: 500;
168
+ letter-spacing: 0.1em;
169
+ text-transform: uppercase;
170
+ cursor: pointer;
171
+ margin-top: 12px;
172
+ transition: all 0.15s;
173
+ }
174
+
175
+ .btn-arrange:hover:not(:disabled) {
176
+ background: #d8ff55;
177
+ }
178
+
179
+ .btn-arrange:disabled {
180
+ background: var(--border);
181
+ color: var(--text-dim);
182
+ cursor: not-allowed;
183
+ }
184
+
185
+ /* Status */
186
+ .status-card {
187
+ display: none;
188
+ padding: 20px 24px;
189
+ background: var(--surface);
190
+ border: 1px solid var(--border);
191
+ border-radius: 3px;
192
+ margin-top: 20px;
193
+ }
194
+
195
+ .status-card.visible { display: block; }
196
+
197
+ .status-eyebrow {
198
+ font-family: 'DM Mono', monospace;
199
+ font-size: 10px;
200
+ letter-spacing: 0.18em;
201
+ text-transform: uppercase;
202
+ color: var(--accent3);
203
+ margin-bottom: 8px;
204
+ }
205
+
206
+ .status-message {
207
+ font-size: 14px;
208
+ color: var(--text-mid);
209
+ line-height: 1.6;
210
+ }
211
+
212
+ .progress-bar-track {
213
+ height: 2px;
214
+ background: var(--border);
215
+ border-radius: 2px;
216
+ margin-top: 16px;
217
+ overflow: hidden;
218
+ }
219
+
220
+ .progress-bar-fill {
221
+ height: 100%;
222
+ background: var(--accent3);
223
+ border-radius: 2px;
224
+ width: 0%;
225
+ transition: width 0.6s ease;
226
+ animation: indeterminate 2s ease-in-out infinite;
227
+ }
228
+
229
+ @keyframes indeterminate {
230
+ 0% { width: 5%; margin-left: 0%; }
231
+ 50% { width: 40%; margin-left: 30%; }
232
+ 100% { width: 5%; margin-left: 95%; }
233
+ }
234
+
235
+ /* Error */
236
+ .error-card {
237
+ display: none;
238
+ padding: 16px 20px;
239
+ background: rgba(255, 60, 60, 0.06);
240
+ border: 1px solid rgba(255, 60, 60, 0.2);
241
+ border-radius: 3px;
242
+ margin-top: 20px;
243
+ font-size: 13px;
244
+ color: #ff6b6b;
245
+ font-family: 'DM Mono', monospace;
246
+ }
247
+
248
+ .error-card.visible { display: block; }
249
+
250
+ /* Results */
251
+ .results-section {
252
+ display: none;
253
+ margin-top: 32px;
254
+ }
255
+
256
+ .results-section.visible { display: block; }
257
+
258
+ .results-header {
259
+ display: flex;
260
+ align-items: baseline;
261
+ gap: 12px;
262
+ margin-bottom: 20px;
263
+ padding-bottom: 16px;
264
+ border-bottom: 1px solid var(--border);
265
+ }
266
+
267
+ .results-tag {
268
+ font-family: 'DM Mono', monospace;
269
+ font-size: 10px;
270
+ letter-spacing: 0.15em;
271
+ text-transform: uppercase;
272
+ padding: 3px 8px;
273
+ border-radius: 2px;
274
+ background: rgba(200, 240, 67, 0.12);
275
+ color: var(--accent);
276
+ }
277
+
278
+ .results-title {
279
+ font-family: 'DM Serif Display', serif;
280
+ font-size: 22px;
281
+ color: var(--text);
282
+ }
283
+
284
+ /* Piano roll */
285
+ .piano-roll-wrap {
286
+ background: var(--surface);
287
+ border: 1px solid var(--border);
288
+ border-radius: 3px;
289
+ overflow: hidden;
290
+ margin-bottom: 20px;
291
+ }
292
+
293
+ .piano-roll-label {
294
+ font-family: 'DM Mono', monospace;
295
+ font-size: 10px;
296
+ letter-spacing: 0.15em;
297
+ text-transform: uppercase;
298
+ color: var(--text-dim);
299
+ padding: 12px 16px 0;
300
+ }
301
+
302
+ .piano-roll-wrap img {
303
+ width: 100%;
304
+ display: block;
305
+ }
306
+
307
+ .piano-roll-legend {
308
+ display: flex;
309
+ gap: 20px;
310
+ padding: 10px 16px 14px;
311
+ }
312
+
313
+ .legend-item {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 7px;
317
+ font-size: 12px;
318
+ color: var(--text-mid);
319
+ }
320
+
321
+ .legend-dot {
322
+ width: 10px;
323
+ height: 10px;
324
+ border-radius: 2px;
325
+ flex-shrink: 0;
326
+ }
327
+
328
+ /* Download */
329
+ .download-row {
330
+ display: flex;
331
+ gap: 10px;
332
+ flex-wrap: wrap;
333
+ }
334
+
335
+ .btn-download {
336
+ padding: 12px 20px;
337
+ background: transparent;
338
+ border: 1px solid var(--border);
339
+ border-radius: 3px;
340
+ color: var(--text);
341
+ font-family: 'DM Mono', monospace;
342
+ font-size: 12px;
343
+ letter-spacing: 0.1em;
344
+ text-transform: uppercase;
345
+ text-decoration: none;
346
+ cursor: pointer;
347
+ transition: all 0.15s;
348
+ display: inline-flex;
349
+ align-items: center;
350
+ gap: 8px;
351
+ }
352
+
353
+ .btn-download:hover {
354
+ border-color: var(--accent);
355
+ color: var(--accent);
356
+ }
357
+
358
+ .btn-download .btn-icon { font-size: 15px; }
359
+
360
+ /* Footer */
361
+ footer {
362
+ margin-top: 64px;
363
+ padding-top: 24px;
364
+ border-top: 1px solid var(--border);
365
+ font-family: 'DM Mono', monospace;
366
+ font-size: 11px;
367
+ color: var(--text-dim);
368
+ display: flex;
369
+ justify-content: space-between;
370
+ flex-wrap: wrap;
371
+ gap: 8px;
372
+ }
373
+
374
+ footer a { color: var(--text-dim); text-decoration: none; }
375
+ footer a:hover { color: var(--accent); }
376
+ </style>
377
+ </head>
378
+ <body>
379
+ <div class="container">
380
+
381
+ <header>
382
+ <div class="eyebrow">KeyArrange · MVP</div>
383
+ <h1>Upload a song.<br><em>Get music you can play.</em></h1>
384
+ <p class="subtitle">Drop any pop song. The pipeline separates stems, transcribes each part, and arranges a two-hand piano version built around physical playability constraints.</p>
385
+ </header>
386
+
387
+ <!-- Upload -->
388
+ <div class="upload-section">
389
+ <div class="drop-zone" id="dropZone">
390
+ <input type="file" id="fileInput" accept=".mp3,.wav" />
391
+ <div class="drop-icon">♩</div>
392
+ <div class="drop-label">Drop an MP3 or WAV here</div>
393
+ <div class="drop-sub">or click to browse</div>
394
+ </div>
395
+
396
+ <div class="file-selected" id="fileSelected">
397
+ <span class="file-name" id="fileName"></span>
398
+ <button class="file-clear" id="fileClear" title="Remove">✕</button>
399
+ </div>
400
+
401
+ <button class="btn-arrange" id="arrangeBtn" disabled>Arrange →</button>
402
+ </div>
403
+
404
+ <!-- Status -->
405
+ <div class="status-card" id="statusCard">
406
+ <div class="status-eyebrow">Processing</div>
407
+ <div class="status-message" id="statusMessage">
408
+ Separating stems, transcribing, arranging… this takes 1–3 minutes depending on song length.
409
+ </div>
410
+ <div class="progress-bar-track">
411
+ <div class="progress-bar-fill" id="progressFill"></div>
412
+ </div>
413
+ </div>
414
+
415
+ <!-- Error -->
416
+ <div class="error-card" id="errorCard"></div>
417
+
418
+ <!-- Results -->
419
+ <div class="results-section" id="resultsSection">
420
+ <div class="results-header">
421
+ <span class="results-tag">Output</span>
422
+ <h2 class="results-title">Your arrangement</h2>
423
+ </div>
424
+
425
+ <div class="piano-roll-wrap" id="pianoRollWrap" style="display:none">
426
+ <div class="piano-roll-label">Piano Roll · time → pitch ↑</div>
427
+ <img id="pianoRollImg" src="" alt="Piano roll visualization" />
428
+ <div class="piano-roll-legend">
429
+ <div class="legend-item">
430
+ <div class="legend-dot" style="background:#4a9eff"></div>
431
+ Right hand — melody
432
+ </div>
433
+ <div class="legend-item">
434
+ <div class="legend-dot" style="background:#ff6b6b"></div>
435
+ Left hand — harmony
436
+ </div>
437
+ <div class="legend-item" style="margin-left:auto; font-size:11px; color:var(--text-dim)">
438
+ Dashed line = middle C
439
+ </div>
440
+ </div>
441
+ </div>
442
+
443
+ <div class="download-row">
444
+ <a class="btn-download" id="midiDownload" href="#" download="keyarrange.mid">
445
+ <span class="btn-icon">↓</span> Download MIDI
446
+ </a>
447
+ </div>
448
+ </div>
449
+
450
+ <footer>
451
+ <span>KeyArrange · audio in, piano arrangement out</span>
452
+ <span>
453
+ <a href="https://github.com/sgoonjan/KeyArrange" target="_blank">GitHub →</a>
454
+ </span>
455
+ </footer>
456
+
457
+ </div>
458
+
459
+ <script>
460
+ const fileInput = document.getElementById('fileInput');
461
+ const dropZone = document.getElementById('dropZone');
462
+ const fileSelected = document.getElementById('fileSelected');
463
+ const fileName = document.getElementById('fileName');
464
+ const fileClear = document.getElementById('fileClear');
465
+ const arrangeBtn = document.getElementById('arrangeBtn');
466
+ const statusCard = document.getElementById('statusCard');
467
+ const statusMsg = document.getElementById('statusMessage');
468
+ const errorCard = document.getElementById('errorCard');
469
+ const results = document.getElementById('resultsSection');
470
+ const pianoWrap = document.getElementById('pianoRollWrap');
471
+ const pianoImg = document.getElementById('pianoRollImg');
472
+ const midiDownload = document.getElementById('midiDownload');
473
+
474
+ let selectedFile = null;
475
+
476
+ // Drag-over styling
477
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
478
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
479
+ dropZone.addEventListener('drop', e => {
480
+ e.preventDefault();
481
+ dropZone.classList.remove('drag-over');
482
+ const f = e.dataTransfer.files[0];
483
+ if (f) setFile(f);
484
+ });
485
+
486
+ fileInput.addEventListener('change', () => {
487
+ if (fileInput.files[0]) setFile(fileInput.files[0]);
488
+ });
489
+
490
+ fileClear.addEventListener('click', clearFile);
491
+
492
+ function setFile(f) {
493
+ selectedFile = f;
494
+ fileName.textContent = f.name;
495
+ fileSelected.classList.add('visible');
496
+ arrangeBtn.disabled = false;
497
+ hide(errorCard); hide(results); hide(statusCard);
498
+ }
499
+
500
+ function clearFile() {
501
+ selectedFile = null;
502
+ fileInput.value = '';
503
+ fileSelected.classList.remove('visible');
504
+ arrangeBtn.disabled = true;
505
+ }
506
+
507
+ arrangeBtn.addEventListener('click', async () => {
508
+ if (!selectedFile) return;
509
+
510
+ hide(errorCard); hide(results);
511
+ show(statusCard);
512
+ arrangeBtn.disabled = true;
513
+
514
+ const form = new FormData();
515
+ form.append('file', selectedFile);
516
+
517
+ try {
518
+ const res = await fetch('/upload', { method: 'POST', body: form });
519
+ const data = await res.json();
520
+
521
+ if (!res.ok) {
522
+ throw new Error(data.detail || 'Something went wrong.');
523
+ }
524
+
525
+ hide(statusCard);
526
+ show(results);
527
+
528
+ midiDownload.href = data.midi_url;
529
+
530
+ if (data.piano_roll_url) {
531
+ pianoImg.src = data.piano_roll_url;
532
+ pianoWrap.style.display = 'block';
533
+ } else {
534
+ pianoWrap.style.display = 'none';
535
+ }
536
+
537
+ } catch (err) {
538
+ hide(statusCard);
539
+ errorCard.textContent = '⚠ ' + err.message;
540
+ show(errorCard);
541
+ } finally {
542
+ arrangeBtn.disabled = false;
543
+ }
544
+ });
545
+
546
+ function show(el) { el.classList.add('visible'); }
547
+ function hide(el) { el.classList.remove('visible'); }
548
+ </script>
549
+ </body>
550
+ </html>