Goonjan Saha commited on
Commit
d10679c
·
unverified ·
2 Parent(s): 0972d55621ebcd

Merge pull request #1 from sgoonjan/building-v1

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"]
README.md CHANGED
@@ -74,19 +74,17 @@ The quick test: can a pianist sight-read it at moderate tempo, clearly assign ha
74
  ## Roadmap
75
 
76
  **v1 — in progress**
77
- - [ ] End-to-end pipeline: audio → MIDI
78
- - [ ] Three core playability transforms (density, span, note cap)
79
- - [ ] Web UI with MIDI download
80
 
81
  **v2 — planned**
82
  - [ ] Chord-aware left hand voicing (root + third + fifth from chord analysis)
83
  - [ ] MuseScore PDF rendering
84
- - [ ] Before/after example gallery
85
 
86
  **v3 — planned**
87
  - [ ] Beat tracking with madmom for better metric strength scoring
88
  - [ ] Melody smoothing — strip ornaments and melisma from vocal transcription
89
- - [ ] Difficulty score on output
90
 
91
  **Later**
92
  - [ ] Fine-tuned arrangement model on POP909 dataset
 
74
  ## Roadmap
75
 
76
  **v1 — in progress**
77
+ - [x] End-to-end pipeline: audio → MIDI
78
+ - [x] Three core playability transforms (density, span, note cap)
79
+ - [x] Web UI with MIDI download
80
 
81
  **v2 — planned**
82
  - [ ] Chord-aware left hand voicing (root + third + fifth from chord analysis)
83
  - [ ] MuseScore PDF rendering
 
84
 
85
  **v3 — planned**
86
  - [ ] Beat tracking with madmom for better metric strength scoring
87
  - [ ] Melody smoothing — strip ornaments and melisma from vocal transcription
 
88
 
89
  **Later**
90
  - [ ] Fine-tuned arrangement model on POP909 dataset
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
@@ -1,7 +1,22 @@
1
- demucs
2
- basic-pitch
3
- librosa
4
- pretty_midi
5
- music21
6
- numpy
7
- soundfile
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core audio processing
2
+ demucs==4.0.0
3
+ basic-pitch==0.3.2
4
+
5
+ # Audio I/O and signal processing
6
+ librosa==0.10.1
7
+ scipy==1.10.1 # Pinned to avoid compatibility issues of newer versions with librosa
8
+ soundfile==0.12.1
9
+ torchaudio==2.1.0 # Pinned to avoid torchcodec requirement in newer versions
10
+
11
+ # Music theory and symbolic manipulation
12
+ 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
+ # 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/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "0.1.0"
src/keyarrange/analysis/__init__.py ADDED
File without changes
src/keyarrange/analysis/beat_tracker.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import librosa
2
+ import numpy as np
3
+ import sys
4
+ import os
5
+
6
+ def get_beat_times(audio_path: str) -> tuple[np.ndarray, float]:
7
+ """
8
+ Uses librosa to analyze the audio file to extract beat times and BPM.
9
+ """
10
+ if not os.path.exists(audio_path):
11
+ raise FileNotFoundError(f"Audio file not found at: {audio_path}")
12
+
13
+ y, sr = librosa.load(audio_path, mono=True)
14
+
15
+ bpm, beat_times = librosa.beat.beat_track(y=y, sr=sr, units='time')
16
+
17
+ if beat_times.size == 0:
18
+ raise ValueError("No beats detected in the audio file.")
19
+
20
+ return beat_times, bpm
21
+
22
+
23
+ if __name__ == "__main__":
24
+ res_1, res_2 = get_beat_times(sys.argv[1])
25
+ print(len(res_1))
26
+ print(res_2)
src/keyarrange/api/__init__.py ADDED
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/cli.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+
4
+ from keyarrange.pipeline import Pipeline
5
+
6
+
7
+
8
+ def main(input_path: str, output_dir: str) -> None:
9
+ handler = logging.StreamHandler()
10
+ handler.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(message)s'))
11
+ logging.getLogger().addHandler(handler)
12
+ logging.getLogger().setLevel(logging.INFO)
13
+
14
+ pipeline = Pipeline(input_path, output_dir)
15
+ arranged_midi_path = pipeline.run()
16
+ print(f"Arranged MIDI saved at: {arranged_midi_path}")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main(sys.argv[1], sys.argv[2])
src/keyarrange/dataclasses.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class Note:
5
+ id: int
6
+ pitch: int
7
+ start: float
8
+ end: float
9
+ velocity: int
10
+ hand: str # 'right' or 'left'
11
+
12
+ @property
13
+ def duration(self) -> float:
14
+ return self.end - self.start
15
+
16
+
src/keyarrange/piano/__init__.py ADDED
File without changes
src/keyarrange/piano/merge.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pretty_midi
2
+ import os
3
+ import sys
4
+ from keyarrange.dataclasses import Note
5
+
6
+ def merge_tracks(right: list[Note], left: list[Note], output_file: str, bpm: float) -> None:
7
+ if not right and not left:
8
+ raise ValueError('No notes to merge')
9
+
10
+ midi = pretty_midi.PrettyMIDI(initial_tempo=bpm)
11
+
12
+ right_hand_instrument = pretty_midi.Instrument(program=0, name='Right Hand')
13
+ left_hand_instrument = pretty_midi.Instrument(program=0, name='Left Hand')
14
+
15
+ for note in right:
16
+ if note.start >= note.end:
17
+ print(f"Warning: Skipping right hand note with start time ({note.start}) >= end time ({note.end})", file=sys.stderr)
18
+ continue
19
+ right_hand_instrument.notes.append(pretty_midi.Note(velocity=note.velocity, pitch=note.pitch, start=note.start, end=note.end))
20
+
21
+ for note in left:
22
+ if note.start >= note.end:
23
+ print(f"Warning: Skipping left hand note with start time ({note.start}) >= end time ({note.end})", file=sys.stderr)
24
+ continue
25
+ left_hand_instrument.notes.append(pretty_midi.Note(velocity=note.velocity, pitch=note.pitch, start=note.start, end=note.end))
26
+
27
+ midi.instruments.append(right_hand_instrument)
28
+ midi.instruments.append(left_hand_instrument)
29
+
30
+ midi.write(output_file)
src/keyarrange/piano/transforms.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from keyarrange.dataclasses import Note
2
+
3
+ def _group_by_onset(notes: list[Note], window_ms: float = 50.0) -> list[list[Note]]:
4
+ sorted_notes = sorted(notes, key=lambda note: note.start)
5
+ groups: list[list[Note]] = []
6
+
7
+ if not sorted_notes:
8
+ return groups
9
+
10
+ current_group: list[Note] = []
11
+ window_seconds = window_ms / 1000.0
12
+
13
+ for note in sorted_notes:
14
+ if not current_group or (note.start - current_group[0].start) <= window_seconds:
15
+ current_group.append(note)
16
+ else:
17
+ groups.append(current_group)
18
+ current_group = [note]
19
+
20
+ if current_group:
21
+ groups.append(current_group)
22
+
23
+ return groups
24
+
25
+ def density_reducer(notes: list[Note], bpm: float, multiplier: int = 1) -> list[Note]:
26
+ window_duration = 0.5 # 500ms
27
+ step_size = 0.25 # 250ms
28
+ dropped_note_ids = set()
29
+
30
+ notes.sort(key=lambda note: note.start)
31
+
32
+ last_note_start_time = 0.0
33
+ if notes:
34
+ last_note_start_time = max(note.start for note in notes)
35
+
36
+ t = 0.0
37
+ while t <= last_note_start_time + window_duration: # Extend windowing slightly past the last note's start time
38
+ window_notes = [note for note in notes if t <= note.start < t + window_duration and note.id not in dropped_note_ids]
39
+
40
+ max_notes_in_window = max(1, int(120 / bpm)) * multiplier
41
+
42
+ if len(window_notes) > max_notes_in_window:
43
+ # Sort by duration (longest first) and keep only the top `max_notes_in_window`
44
+ window_notes.sort(key=lambda note: note.duration, reverse=True)
45
+ notes_to_drop = window_notes[max_notes_in_window:]
46
+
47
+ for note in notes_to_drop:
48
+ dropped_note_ids.add(note.id)
49
+
50
+ t += step_size
51
+
52
+ return [note for note in notes if note.id not in dropped_note_ids]
53
+
54
+ def span_enforcer(notes: list[Note], max_span: int = 12, hand: str = "right") -> list[Note]:
55
+ processed_notes: list[Note] = []
56
+
57
+ onset_groups = _group_by_onset(notes)
58
+
59
+ for group in onset_groups:
60
+ current_group = sorted(group, key=lambda note: note.pitch)
61
+
62
+ while len(current_group) > 1 and (current_group[-1].pitch - current_group[0].pitch) > max_span:
63
+ if hand == "right":
64
+ # For right hand, drop the lowest pitch note to reduce span
65
+ current_group.pop(0)
66
+ elif hand == "left":
67
+ # For left hand, drop the highest pitch note to reduce span
68
+ current_group.pop(-1)
69
+ else:
70
+ # Should not happen with valid input, but as a safeguard
71
+ break
72
+ processed_notes.extend(current_group)
73
+
74
+ return processed_notes
75
+
76
+ def note_cap(notes: list[Note], max_notes: int = 3) -> list[Note]:
77
+ processed_notes: list[Note] = []
78
+
79
+ onset_groups = _group_by_onset(notes)
80
+
81
+ for group in onset_groups:
82
+ current_group = sorted(group, key=lambda note: note.duration)
83
+
84
+ while len(current_group) > max_notes:
85
+ current_group.pop(0) # Drop the shortest duration note
86
+ processed_notes.extend(current_group)
87
+
88
+ return processed_notes
src/keyarrange/pipeline.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline coordinator — owns directory structure and stage sequencing."""
2
+ import logging
3
+ from pathlib import Path
4
+
5
+ from keyarrange.separation.demucs_runner import separate
6
+ from keyarrange.transcription.basic_pitch_transcriptor import transcribe_stem
7
+ from keyarrange.analysis.beat_tracker import get_beat_times
8
+ from keyarrange.structure.midi_parser import load_midi
9
+ from keyarrange.structure.quantize import quantize_to_beats
10
+ from keyarrange.piano.merge import merge_tracks
11
+ from keyarrange.piano.transforms import density_reducer, span_enforcer, note_cap
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Pipeline:
17
+ """Disk-based pipeline: each stage writes output before the next runs."""
18
+
19
+ def __init__(self, input_path: str, output_dir: str):
20
+ self.input_path = Path(input_path)
21
+
22
+ if not self.input_path.exists():
23
+ raise ValueError(f"Input file does not exist: {input_path}")
24
+
25
+ song_name = self.input_path.stem
26
+ base_dir = Path(output_dir) / song_name
27
+ self.base_dir = base_dir
28
+
29
+ self.stems_dir = base_dir / "stems"
30
+ self.transcriptions_dir = base_dir / "transcriptions"
31
+ self.arranged_dir = base_dir / "arranged"
32
+
33
+ self.stems_dir.mkdir(parents=True, exist_ok=True)
34
+ self.transcriptions_dir.mkdir(parents=True, exist_ok=True)
35
+ self.arranged_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ def run(self) -> str:
38
+ output_file_path = self.arranged_dir / "arranged.mid"
39
+
40
+ logger.info("Running stem separation...")
41
+ stem_paths = separate(str(self.input_path), str(self.stems_dir))
42
+ vocals_audio_path = stem_paths["vocals"]
43
+ bass_audio_path = stem_paths["bass"]
44
+
45
+ logger.info("Transcribing vocal stem...")
46
+ vocals_midi_path = transcribe_stem(vocals_audio_path, str(self.transcriptions_dir))
47
+
48
+ logger.info("Transcribing bass stem...")
49
+ bass_midi_path = transcribe_stem(bass_audio_path, str(self.transcriptions_dir))
50
+
51
+ logger.info("Getting beat times...")
52
+ beat_times, bpm = get_beat_times(str(self.input_path))
53
+
54
+ logger.info("Loading vocal MIDI...")
55
+ right_notes = load_midi(str(vocals_midi_path), hand="right")
56
+
57
+ logger.info("Loading bass MIDI...")
58
+ left_notes = load_midi(str(bass_midi_path), hand="left")
59
+
60
+ logger.info("Quantizing left hand notes...")
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, multiplier=2) # Allow density relaxation for vocals
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)
70
+ left_notes = span_enforcer(left_notes, max_span=12, hand="left")
71
+ left_notes = note_cap(left_notes, max_notes=3)
72
+
73
+ logger.info("Merging tracks...")
74
+ merge_tracks(right_notes, left_notes, str(output_file_path), bpm)
75
+
76
+ logger.info(f"Pipeline complete: {output_file_path}")
77
+ return str(output_file_path)
src/keyarrange/render/__init__.py ADDED
File without changes
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/separation/__init__.py ADDED
File without changes
src/keyarrange/separation/demucs_runner.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def separate(audio_path: str, output_dir: str) -> dict[str, str]:
7
+ """
8
+ Run Demucs source separation on an audio file.
9
+
10
+ Returns:
11
+ Dictionary mapping stem names to absolute file paths
12
+
13
+ Raises:
14
+ RuntimeError: If expected stem files are missing after separation
15
+ """
16
+ audio_path = Path(audio_path).resolve()
17
+ output_dir = Path(output_dir).resolve()
18
+
19
+ # Run Demucs separation
20
+ subprocess.run(
21
+ ["python", "-m", "demucs", "-n", "htdemucs", "--out", str(output_dir), str(audio_path)],
22
+ check=True
23
+ )
24
+
25
+ # Demucs creates nested structure: {output_dir}/htdemucs/{song_name}/
26
+ song_name = audio_path.stem
27
+ stems_base = output_dir / "htdemucs" / song_name
28
+
29
+ # Locate all four expected stems
30
+ expected_stems = ["vocals", "bass", "drums", "other"]
31
+ stem_paths = {}
32
+ missing_stems = []
33
+
34
+ for stem in expected_stems:
35
+ stem_file = stems_base / f"{stem}.wav"
36
+ if stem_file.exists():
37
+ stem_paths[stem] = str(stem_file.resolve())
38
+ else:
39
+ missing_stems.append(stem)
40
+
41
+ if missing_stems:
42
+ raise RuntimeError(
43
+ f"Missing stems after Demucs separation: {missing_stems}. "
44
+ f"Looked in: {stems_base}"
45
+ )
46
+
47
+ return stem_paths
48
+
49
+
50
+ if __name__ == "__main__":
51
+ result = separate(sys.argv[1], sys.argv[2])
52
+ for stem, path in result.items():
53
+ print(f"{stem}: {path}")
src/keyarrange/structure/__init__.py ADDED
File without changes
src/keyarrange/structure/midi_parser.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import pretty_midi
4
+ from keyarrange.dataclasses import Note
5
+
6
+ def load_midi(path: str, hand: str) -> list[Note]:
7
+ """
8
+ Extracts note information from MIDI file into a list of Note dataclasses.
9
+ """
10
+ if hand not in ['right', 'left']:
11
+ raise ValueError("hand must be either 'right' or 'left'")
12
+
13
+ if not os.path.exists(path):
14
+ raise FileNotFoundError(f"MIDI file not found at {path}")
15
+
16
+ midi_data = pretty_midi.PrettyMIDI(path)
17
+
18
+ notes: list[Note] = []
19
+ for instrument in midi_data.instruments:
20
+ for pm_note in instrument.notes:
21
+ notes.append(Note(
22
+ id = len(notes),
23
+ pitch=pm_note.pitch,
24
+ start=pm_note.start,
25
+ end=pm_note.end,
26
+ velocity=pm_note.velocity,
27
+ hand=hand
28
+ ))
29
+
30
+ if not notes:
31
+ raise ValueError('No notes found in MIDI file')
32
+
33
+ notes.sort(key=lambda note: note.start) # Ordered for easier processing
34
+
35
+ return notes
36
+
37
+
38
+ if __name__ == "__main__":
39
+ result = load_midi(sys.argv[1], sys.argv[2])
40
+ print(len(result))
src/keyarrange/structure/quantize.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import numpy as np
3
+ from keyarrange.dataclasses import Note
4
+
5
+ def quantize_to_beats(notes: list[Note], beat_times: np.ndarray) -> list[Note]:
6
+ if beat_times.size == 0:
7
+ raise ValueError("beat_times cannot be empty.")
8
+
9
+ quantized_notes = []
10
+ for note in notes:
11
+ nearest_beat_index = np.argmin(np.abs(beat_times - note.start))
12
+ new_start = beat_times[nearest_beat_index]
13
+ new_end = new_start + note.duration
14
+
15
+ quantized_note = Note(
16
+ id=note.id,
17
+ start=new_start,
18
+ end=new_end,
19
+ pitch=note.pitch,
20
+ velocity=note.velocity,
21
+ hand=note.hand,
22
+ )
23
+ quantized_notes.append(quantized_note)
24
+
25
+ return quantized_notes
src/keyarrange/transcription/__init__.py ADDED
File without changes
src/keyarrange/transcription/basic_pitch_transcriptor.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+ from basic_pitch.inference import predict
4
+
5
+ def transcribe_stem(audio_path: str, output_dir: str) -> Path:
6
+ """
7
+ Transcribe audio stem to MIDI using Basic Pitch.
8
+ """
9
+ audio_path = Path(audio_path).resolve()
10
+ output_dir = Path(output_dir).resolve()
11
+
12
+ if not audio_path.exists():
13
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
14
+
15
+ output_dir.mkdir(parents=True, exist_ok=True)
16
+
17
+ stem_name = audio_path.stem
18
+ output_path = output_dir / f"{stem_name}_transcription.mid"
19
+
20
+ print(f"Starting transcription for {audio_path.name}...")
21
+
22
+ # Call predict with the string representation of the audio_path
23
+ _, midi_data, _ = predict(
24
+ str(audio_path)
25
+ )
26
+
27
+ # Save the PrettyMIDI object to the specified output path
28
+ midi_data.write(str(output_path))
29
+
30
+ print(f"Transcription complete. MIDI saved to {output_path}")
31
+
32
+ return output_path
33
+
34
+ if __name__ == "__main__":
35
+ output_path = transcribe_stem(sys.argv[1], sys.argv[2])
36
+ print(f"Transcribed stem: {output_path}")
tests/testing_bass.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import soundfile as sf
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+
5
+ # Load the bass stem
6
+ bass, sr = sf.read('../data/output/a_thousand_years/stems/htdemucs/a_thousand_years/bass.wav')
7
+
8
+ # Check if there's actually audio content
9
+ print(f"Sample rate: {sr}")
10
+ print(f"Duration: {len(bass) / sr:.2f} seconds")
11
+ print(f"Max amplitude: {np.max(np.abs(bass)):.6f}")
12
+ print(f"RMS energy: {np.sqrt(np.mean(bass**2)):.6f}")
13
+
14
+ # Quick playability check
15
+ if np.max(np.abs(bass)) < 0.001:
16
+ print("⚠️ Bass stem appears to be silent or very quiet")
17
+ else:
18
+ print("✓ Bass stem has content")
19
+
20
+ plt.figure(figsize=(12, 4))
21
+ plt.plot(bass[:sr*10]) # First 10 seconds
22
+ plt.title("Bass waveform")
23
+ plt.show()
web/index.html ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ <!-- MIDI player web component — handles audio synthesis + scrolling piano roll visualization -->
9
+ <script src="https://cdn.jsdelivr.net/combine/npm/tone@14.7.58,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.5.0"></script>
10
+
11
+ <style>
12
+ @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');
13
+
14
+ :root {
15
+ --bg: #0d0d0d;
16
+ --surface: #141414;
17
+ --surface2: #1a1a1a;
18
+ --border: #242424;
19
+ --accent: #c8f043;
20
+ --accent2: #f0c843;
21
+ --accent3: #43c8f0;
22
+ --text: #e8e8e0;
23
+ --text-dim: #666;
24
+ --text-mid: #999;
25
+ }
26
+
27
+ * { box-sizing: border-box; margin: 0; padding: 0; }
28
+
29
+ body {
30
+ background: var(--bg);
31
+ color: var(--text);
32
+ font-family: 'DM Sans', sans-serif;
33
+ font-weight: 300;
34
+ min-height: 100vh;
35
+ padding: 48px 24px 80px;
36
+ }
37
+
38
+ .container { max-width: 760px; margin: 0 auto; }
39
+
40
+ header {
41
+ border-bottom: 1px solid var(--border);
42
+ padding-bottom: 32px;
43
+ margin-bottom: 40px;
44
+ }
45
+
46
+ .eyebrow {
47
+ font-family: 'DM Mono', monospace;
48
+ font-size: 11px;
49
+ letter-spacing: 0.2em;
50
+ color: var(--accent);
51
+ text-transform: uppercase;
52
+ margin-bottom: 12px;
53
+ }
54
+
55
+ h1 {
56
+ font-family: 'DM Serif Display', serif;
57
+ font-size: clamp(28px, 5vw, 44px);
58
+ line-height: 1.1;
59
+ color: var(--text);
60
+ margin-bottom: 16px;
61
+ }
62
+
63
+ h1 em { font-style: italic; color: var(--accent); }
64
+
65
+ .subtitle {
66
+ font-size: 14px;
67
+ color: var(--text-mid);
68
+ max-width: 480px;
69
+ line-height: 1.7;
70
+ }
71
+
72
+ /* Upload */
73
+ .upload-section { margin-bottom: 32px; }
74
+
75
+ .drop-zone {
76
+ border: 1px dashed var(--border);
77
+ border-radius: 3px;
78
+ padding: 40px 32px;
79
+ text-align: center;
80
+ background: var(--surface);
81
+ cursor: pointer;
82
+ transition: all 0.2s;
83
+ position: relative;
84
+ }
85
+
86
+ .drop-zone:hover, .drop-zone.drag-over {
87
+ border-color: var(--accent);
88
+ background: rgba(200, 240, 67, 0.03);
89
+ }
90
+
91
+ .drop-zone input[type="file"] {
92
+ position: absolute;
93
+ inset: 0;
94
+ opacity: 0;
95
+ cursor: pointer;
96
+ width: 100%;
97
+ height: 100%;
98
+ }
99
+
100
+ .drop-icon { font-size: 28px; margin-bottom: 12px; color: var(--text-dim); }
101
+ .drop-label { font-size: 14px; color: var(--text-mid); margin-bottom: 6px; }
102
+ .drop-sub {
103
+ font-family: 'DM Mono', monospace;
104
+ font-size: 11px;
105
+ color: var(--text-dim);
106
+ letter-spacing: 0.1em;
107
+ text-transform: uppercase;
108
+ }
109
+
110
+ .file-selected {
111
+ margin-top: 14px;
112
+ display: none;
113
+ align-items: center;
114
+ gap: 10px;
115
+ padding: 12px 16px;
116
+ background: rgba(200, 240, 67, 0.06);
117
+ border: 1px solid rgba(200, 240, 67, 0.2);
118
+ border-radius: 3px;
119
+ }
120
+ .file-selected.visible { display: flex; }
121
+
122
+ .file-name {
123
+ font-family: 'DM Mono', monospace;
124
+ font-size: 12px;
125
+ color: var(--accent);
126
+ flex: 1;
127
+ overflow: hidden;
128
+ text-overflow: ellipsis;
129
+ white-space: nowrap;
130
+ }
131
+
132
+ .file-clear {
133
+ background: none;
134
+ border: none;
135
+ color: var(--text-dim);
136
+ cursor: pointer;
137
+ font-size: 16px;
138
+ line-height: 1;
139
+ padding: 0 4px;
140
+ }
141
+ .file-clear:hover { color: var(--text); }
142
+
143
+ .btn-arrange {
144
+ width: 100%;
145
+ padding: 16px;
146
+ background: var(--accent);
147
+ color: #0d0d0d;
148
+ border: none;
149
+ border-radius: 3px;
150
+ font-family: 'DM Mono', monospace;
151
+ font-size: 13px;
152
+ font-weight: 500;
153
+ letter-spacing: 0.1em;
154
+ text-transform: uppercase;
155
+ cursor: pointer;
156
+ margin-top: 12px;
157
+ transition: all 0.15s;
158
+ }
159
+ .btn-arrange:hover:not(:disabled) { background: #d8ff55; }
160
+ .btn-arrange:disabled {
161
+ background: var(--border);
162
+ color: var(--text-dim);
163
+ cursor: not-allowed;
164
+ }
165
+
166
+ /* Status */
167
+ .status-card {
168
+ display: none;
169
+ padding: 20px 24px;
170
+ background: var(--surface);
171
+ border: 1px solid var(--border);
172
+ border-radius: 3px;
173
+ margin-top: 20px;
174
+ }
175
+ .status-card.visible { display: block; }
176
+
177
+ .status-eyebrow {
178
+ font-family: 'DM Mono', monospace;
179
+ font-size: 10px;
180
+ letter-spacing: 0.18em;
181
+ text-transform: uppercase;
182
+ color: var(--accent3);
183
+ margin-bottom: 8px;
184
+ }
185
+
186
+ .status-message { font-size: 14px; color: var(--text-mid); line-height: 1.6; }
187
+
188
+ .progress-bar-track {
189
+ height: 2px;
190
+ background: var(--border);
191
+ border-radius: 2px;
192
+ margin-top: 16px;
193
+ overflow: hidden;
194
+ }
195
+
196
+ .progress-bar-fill {
197
+ height: 100%;
198
+ background: var(--accent3);
199
+ border-radius: 2px;
200
+ animation: indeterminate 2s ease-in-out infinite;
201
+ }
202
+
203
+ @keyframes indeterminate {
204
+ 0% { width: 5%; margin-left: 0%; }
205
+ 50% { width: 40%; margin-left: 30%; }
206
+ 100% { width: 5%; margin-left: 95%; }
207
+ }
208
+
209
+ /* Error */
210
+ .error-card {
211
+ display: none;
212
+ padding: 16px 20px;
213
+ background: rgba(255, 60, 60, 0.06);
214
+ border: 1px solid rgba(255, 60, 60, 0.2);
215
+ border-radius: 3px;
216
+ margin-top: 20px;
217
+ font-size: 13px;
218
+ color: #ff6b6b;
219
+ font-family: 'DM Mono', monospace;
220
+ }
221
+ .error-card.visible { display: block; }
222
+
223
+ /* Results */
224
+ .results-section { display: none; margin-top: 32px; }
225
+ .results-section.visible { display: block; }
226
+
227
+ .results-header {
228
+ display: flex;
229
+ align-items: baseline;
230
+ gap: 12px;
231
+ margin-bottom: 20px;
232
+ padding-bottom: 16px;
233
+ border-bottom: 1px solid var(--border);
234
+ }
235
+
236
+ .results-tag {
237
+ font-family: 'DM Mono', monospace;
238
+ font-size: 10px;
239
+ letter-spacing: 0.15em;
240
+ text-transform: uppercase;
241
+ padding: 3px 8px;
242
+ border-radius: 2px;
243
+ background: rgba(200, 240, 67, 0.12);
244
+ color: var(--accent);
245
+ }
246
+
247
+ .results-title {
248
+ font-family: 'DM Serif Display', serif;
249
+ font-size: 22px;
250
+ color: var(--text);
251
+ }
252
+
253
+ /* MIDI player container */
254
+ .player-wrap {
255
+ background: var(--surface);
256
+ border: 1px solid var(--border);
257
+ border-radius: 3px;
258
+ overflow: hidden;
259
+ margin-bottom: 20px;
260
+ }
261
+
262
+ .player-label {
263
+ font-family: 'DM Mono', monospace;
264
+ font-size: 10px;
265
+ letter-spacing: 0.15em;
266
+ text-transform: uppercase;
267
+ color: var(--text-dim);
268
+ padding: 12px 16px 8px;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: space-between;
272
+ }
273
+
274
+ .player-legend {
275
+ display: flex;
276
+ gap: 16px;
277
+ }
278
+
279
+ .legend-item {
280
+ display: flex;
281
+ align-items: center;
282
+ gap: 6px;
283
+ font-size: 11px;
284
+ color: var(--text-dim);
285
+ }
286
+
287
+ .legend-dot {
288
+ width: 9px;
289
+ height: 9px;
290
+ border-radius: 2px;
291
+ flex-shrink: 0;
292
+ }
293
+
294
+ /*
295
+ html-midi-player uses a shadow DOM so deep CSS overrides are limited.
296
+ These CSS custom properties are what the component officially exposes.
297
+ The visualizer background and note colors are the most impactful ones.
298
+ */
299
+ midi-player {
300
+ display: block;
301
+ width: 100%;
302
+ background: var(--surface2);
303
+ border-top: 1px solid var(--border);
304
+ }
305
+
306
+ midi-visualizer {
307
+ display: block;
308
+ width: 100%;
309
+ /* Note colors per track index — track 0 = right hand, track 1 = left hand */
310
+ --midi-visualizer-notes-color: #4a9eff; /* fallback / right hand */
311
+ background: #0a0a0a;
312
+ min-height: 160px;
313
+ }
314
+
315
+ /* Download */
316
+ .download-row { display: flex; gap: 10px; flex-wrap: wrap; }
317
+
318
+ .btn-download {
319
+ padding: 12px 20px;
320
+ background: transparent;
321
+ border: 1px solid var(--border);
322
+ border-radius: 3px;
323
+ color: var(--text);
324
+ font-family: 'DM Mono', monospace;
325
+ font-size: 12px;
326
+ letter-spacing: 0.1em;
327
+ text-transform: uppercase;
328
+ text-decoration: none;
329
+ cursor: pointer;
330
+ transition: all 0.15s;
331
+ display: inline-flex;
332
+ align-items: center;
333
+ gap: 8px;
334
+ }
335
+ .btn-download:hover { border-color: var(--accent); color: var(--accent); }
336
+ .btn-download .btn-icon { font-size: 15px; }
337
+
338
+ /* Footer */
339
+ footer {
340
+ margin-top: 64px;
341
+ padding-top: 24px;
342
+ border-top: 1px solid var(--border);
343
+ font-family: 'DM Mono', monospace;
344
+ font-size: 11px;
345
+ color: var(--text-dim);
346
+ display: flex;
347
+ justify-content: space-between;
348
+ flex-wrap: wrap;
349
+ gap: 8px;
350
+ }
351
+ footer a { color: var(--text-dim); text-decoration: none; }
352
+ footer a:hover { color: var(--accent); }
353
+ </style>
354
+ </head>
355
+ <body>
356
+ <div class="container">
357
+
358
+ <header>
359
+ <div class="eyebrow">KeyArrange · MVP</div>
360
+ <h1>Upload a song.<br><em>Get music you can play.</em></h1>
361
+ <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>
362
+ </header>
363
+
364
+ <!-- Upload -->
365
+ <div class="upload-section">
366
+ <div class="drop-zone" id="dropZone">
367
+ <input type="file" id="fileInput" accept=".mp3,.wav" />
368
+ <div class="drop-icon">♩</div>
369
+ <div class="drop-label">Drop an MP3 or WAV here</div>
370
+ <div class="drop-sub">or click to browse</div>
371
+ </div>
372
+
373
+ <div class="file-selected" id="fileSelected">
374
+ <span class="file-name" id="fileName"></span>
375
+ <button class="file-clear" id="fileClear" title="Remove">✕</button>
376
+ </div>
377
+
378
+ <button class="btn-arrange" id="arrangeBtn" disabled>Arrange →</button>
379
+ </div>
380
+
381
+ <!-- Status -->
382
+ <div class="status-card" id="statusCard">
383
+ <div class="status-eyebrow">Processing</div>
384
+ <div class="status-message">
385
+ Separating stems, transcribing, arranging… this takes 1–3 minutes depending on song length.
386
+ </div>
387
+ <div class="progress-bar-track">
388
+ <div class="progress-bar-fill"></div>
389
+ </div>
390
+ </div>
391
+
392
+ <!-- Error -->
393
+ <div class="error-card" id="errorCard"></div>
394
+
395
+ <!-- Results -->
396
+ <div class="results-section" id="resultsSection">
397
+ <div class="results-header">
398
+ <span class="results-tag">Output</span>
399
+ <h2 class="results-title">Your arrangement</h2>
400
+ </div>
401
+
402
+ <div class="player-wrap">
403
+ <div class="player-label">
404
+ <span>Piano Roll · press play to hear it</span>
405
+ <div class="player-legend">
406
+ <div class="legend-item">
407
+ <div class="legend-dot" style="background:#ff6b6b"></div>
408
+ Playing now
409
+ </div>
410
+ <div class="legend-item">
411
+ <div class="legend-dot" style="background:#4a9eff"></div>
412
+ Upcoming
413
+ </div>
414
+ </div>
415
+ </div>
416
+
417
+ <!--
418
+ midi-visualizer renders the scrolling piano roll.
419
+ midi-player handles play/pause/seek and audio via Magenta soundfont.
420
+ They are linked by the visualizer="#midiVisualizer" attribute.
421
+ sound-font enables the Magenta piano soundfont (requires internet).
422
+ -->
423
+ <midi-visualizer type="piano-roll" id="midiVisualizer"></midi-visualizer>
424
+ <midi-player id="midiPlayer" sound-font visualizer="#midiVisualizer"></midi-player>
425
+ </div>
426
+
427
+ <div class="download-row">
428
+ <a class="btn-download" id="midiDownload" href="#" download="keyarrange.mid">
429
+ <span class="btn-icon">↓</span> Download MIDI
430
+ </a>
431
+ </div>
432
+ </div>
433
+
434
+ <footer>
435
+ <span>KeyArrange · audio in, piano arrangement out</span>
436
+ <span>
437
+ <a href="https://github.com/sgoonjan/KeyArrange" target="_blank">GitHub →</a>
438
+ </span>
439
+ </footer>
440
+
441
+ </div>
442
+
443
+ <script>
444
+ const fileInput = document.getElementById('fileInput');
445
+ const dropZone = document.getElementById('dropZone');
446
+ const fileSelected = document.getElementById('fileSelected');
447
+ const fileName = document.getElementById('fileName');
448
+ const fileClear = document.getElementById('fileClear');
449
+ const arrangeBtn = document.getElementById('arrangeBtn');
450
+ const statusCard = document.getElementById('statusCard');
451
+ const errorCard = document.getElementById('errorCard');
452
+ const results = document.getElementById('resultsSection');
453
+ const midiPlayer = document.getElementById('midiPlayer');
454
+ const midiDownload = document.getElementById('midiDownload');
455
+
456
+ let selectedFile = null;
457
+
458
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
459
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
460
+ dropZone.addEventListener('drop', e => {
461
+ e.preventDefault();
462
+ dropZone.classList.remove('drag-over');
463
+ const f = e.dataTransfer.files[0];
464
+ if (f) setFile(f);
465
+ });
466
+
467
+ fileInput.addEventListener('change', () => {
468
+ if (fileInput.files[0]) setFile(fileInput.files[0]);
469
+ });
470
+
471
+ fileClear.addEventListener('click', clearFile);
472
+
473
+ function setFile(f) {
474
+ selectedFile = f;
475
+ fileName.textContent = f.name;
476
+ fileSelected.classList.add('visible');
477
+ arrangeBtn.disabled = false;
478
+ hide(errorCard); hide(results); hide(statusCard);
479
+ }
480
+
481
+ function clearFile() {
482
+ selectedFile = null;
483
+ fileInput.value = '';
484
+ fileSelected.classList.remove('visible');
485
+ arrangeBtn.disabled = true;
486
+ }
487
+
488
+ arrangeBtn.addEventListener('click', async () => {
489
+ if (!selectedFile) return;
490
+
491
+ hide(errorCard); hide(results);
492
+ show(statusCard);
493
+ arrangeBtn.disabled = true;
494
+
495
+ const form = new FormData();
496
+ form.append('file', selectedFile);
497
+
498
+ try {
499
+ const res = await fetch('/upload', { method: 'POST', body: form });
500
+ const data = await res.json();
501
+
502
+ if (!res.ok) throw new Error(data.detail || 'Something went wrong.');
503
+
504
+ hide(statusCard);
505
+ show(results);
506
+
507
+ // Setting src on midi-player triggers it to fetch the MIDI and load it
508
+ // into both the player controls and the linked visualizer automatically.
509
+ midiPlayer.setAttribute('src', data.midi_url);
510
+ midiDownload.href = data.midi_url;
511
+
512
+ } catch (err) {
513
+ hide(statusCard);
514
+ errorCard.textContent = '⚠ ' + err.message;
515
+ show(errorCard);
516
+ } finally {
517
+ arrangeBtn.disabled = false;
518
+ }
519
+ });
520
+
521
+ function show(el) { el.classList.add('visible'); }
522
+ function hide(el) { el.classList.remove('visible'); }
523
+ </script>
524
+ </body>
525
+ </html>