fomext commited on
Commit
4c0ed2e
·
verified ·
1 Parent(s): 3ff3996

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +32 -0
  2. main.py +295 -0
  3. requirements.txt +20 -0
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── NoteGrabber API — Docker image ───────────────────────────────────────────
2
+ # Builds a lean production image for studio.cloudfom.org's Note Grabber backend.
3
+ #
4
+ # Build: docker build -t notegrabber-api .
5
+ # Run: docker run -p 8000:8000 notegrabber-api
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ FROM python:3.11-slim
9
+
10
+ # System dependencies needed by librosa / soundfile (used by basic-pitch)
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ libsndfile1 \
13
+ ffmpeg \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+
18
+ # Install Python dependencies first (layer-cached unless requirements change)
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy application code
23
+ COPY main.py .
24
+
25
+ # Pre-download the basic-pitch model weights at build time so cold-starts
26
+ # don't hit the network at runtime. (The model is ~30 MB.)
27
+ RUN python -c "from basic_pitch import ICASSP_2022_MODEL_PATH; print('Model path:', ICASSP_2022_MODEL_PATH)"
28
+
29
+ EXPOSE 8000
30
+
31
+ # Use --workers 2 in production (CPU-bound; more workers = more RAM for models)
32
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
main.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NoteGrabber API Server
3
+ ======================
4
+ FastAPI backend for studio.cloudfom.org's AI Note Grabber feature.
5
+
6
+ Powered by Spotify's basic-pitch (the same engine as NeuralNote).
7
+ Accepts audio uploads, runs transcription, and returns piano-roll-ready
8
+ note data as JSON plus an optional MIDI file download.
9
+
10
+ Install dependencies:
11
+ pip install fastapi uvicorn python-multipart basic-pitch pretty-midi
12
+
13
+ Run:
14
+ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
15
+ """
16
+
17
+ import io
18
+ import os
19
+ import base64
20
+ import tempfile
21
+ import logging
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ import pretty_midi
26
+ import uvicorn
27
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
28
+ from fastapi.middleware.cors import CORSMiddleware
29
+ from fastapi.responses import JSONResponse
30
+ from pydantic import BaseModel, Field
31
+
32
+ # ── basic-pitch imports ──────────────────────────────────────────────────────
33
+ from basic_pitch.inference import predict, Model
34
+ from basic_pitch import ICASSP_2022_MODEL_PATH
35
+
36
+ # ── Logging ──────────────────────────────────────────────────────────────────
37
+ logging.basicConfig(level=logging.INFO)
38
+ logger = logging.getLogger("notegrabber")
39
+
40
+ # ── App setup ────────────────────────────────────────────────────────────────
41
+ app = FastAPI(
42
+ title="NoteGrabber API",
43
+ description="Audio-to-MIDI transcription service for studio.cloudfom.org",
44
+ version="1.0.0",
45
+ )
46
+
47
+ app.add_middleware(
48
+ CORSMiddleware,
49
+ # Lock this down to your actual frontend domain in production
50
+ allow_origins=[
51
+ "https://studio.cloudfom.org",
52
+ "http://localhost:3000", # local dev
53
+ "http://localhost:5173", # Vite dev
54
+ ],
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+ # ── Load model once at startup (not per-request) ─────────────────────────────
61
+ logger.info("Loading basic-pitch model…")
62
+ _MODEL = Model(ICASSP_2022_MODEL_PATH)
63
+ logger.info("Model loaded ✓")
64
+
65
+ # ── Supported input formats ───────────────────────────────────────────────────
66
+ SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aiff"}
67
+ MAX_FILE_SIZE_MB = 50
68
+
69
+
70
+ # ── Response schemas ──────────────────────────────────────────────────────────
71
+ class NoteEvent(BaseModel):
72
+ """A single transcribed note, ready for your piano roll."""
73
+
74
+ pitch: int = Field(..., description="MIDI note number (0–127)")
75
+ pitch_name: str = Field(..., description="Human-readable name, e.g. 'C4'")
76
+ start_time: float = Field(..., description="Note start in seconds")
77
+ end_time: float = Field(..., description="Note end in seconds")
78
+ duration: float = Field(..., description="Duration in seconds")
79
+ velocity: int = Field(..., description="MIDI velocity (0–127)")
80
+ confidence: float = Field(..., description="Model confidence (0.0–1.0)")
81
+ # Pitch-bend data (semitone offsets, one per time step within this note)
82
+ pitch_bend: Optional[list[float]] = Field(
83
+ None, description="Sub-semitone pitch bend offsets if detected"
84
+ )
85
+
86
+
87
+ class TranscriptionResult(BaseModel):
88
+ note_count: int
89
+ duration_seconds: float
90
+ tempo_bpm: Optional[float]
91
+ notes: list[NoteEvent]
92
+ # base64-encoded .mid file for direct download / import
93
+ midi_base64: str
94
+ # Settings echoed back so the client can cache them
95
+ settings: dict
96
+
97
+
98
+ # ── Helper: convert pretty_midi → NoteEvent list ─────────────────────────────
99
+ def midi_to_note_events(midi_data: pretty_midi.PrettyMIDI) -> list[NoteEvent]:
100
+ events: list[NoteEvent] = []
101
+
102
+ for instrument in midi_data.instruments:
103
+ for note in instrument.notes:
104
+ pitch_name = pretty_midi.note_number_to_name(note.pitch)
105
+ # pitch_bends live on the instrument, not the note directly;
106
+ # collect bends that fall within this note's time window
107
+ bends_in_window = [
108
+ pb.pitch / 8192.0 # normalise to semitones (-2 … +2)
109
+ for pb in instrument.pitch_bends
110
+ if note.start <= pb.time < note.end
111
+ ]
112
+
113
+ events.append(
114
+ NoteEvent(
115
+ pitch=note.pitch,
116
+ pitch_name=pitch_name,
117
+ start_time=round(note.start, 4),
118
+ end_time=round(note.end, 4),
119
+ duration=round(note.end - note.start, 4),
120
+ velocity=note.velocity,
121
+ # pretty_midi doesn't store per-note confidence directly;
122
+ # basic-pitch stuffs it into velocity (0–127). Normalise.
123
+ confidence=round(note.velocity / 127.0, 3),
124
+ pitch_bend=bends_in_window if bends_in_window else None,
125
+ )
126
+ )
127
+
128
+ # Sort chronologically
129
+ events.sort(key=lambda n: n.start_time)
130
+ return events
131
+
132
+
133
+ # ── Helper: MIDI → base64 string ─────────────────────────────────────────────
134
+ def midi_to_base64(midi_data: pretty_midi.PrettyMIDI) -> str:
135
+ buf = io.BytesIO()
136
+ midi_data.write(buf)
137
+ buf.seek(0)
138
+ return base64.b64encode(buf.read()).decode("utf-8")
139
+
140
+
141
+ # ── Helper: clamp and validate user params ────────────────────────────────────
142
+ def _clamp(value: float, lo: float, hi: float) -> float:
143
+ return max(lo, min(hi, value))
144
+
145
+
146
+ # ── Routes ────────────────────────────────────────────────────────────────────
147
+
148
+ @app.get("/health")
149
+ async def health():
150
+ """Simple liveness probe."""
151
+ return {"status": "ok", "model": "basic-pitch (ICASSP 2022)"}
152
+
153
+
154
+ @app.post("/transcribe", response_model=TranscriptionResult)
155
+ async def transcribe(
156
+ audio: UploadFile = File(..., description="Audio file to transcribe"),
157
+ # ── Transcription parameters (all optional, sensible defaults) ──
158
+ onset_threshold: float = Form(
159
+ 0.5,
160
+ description="Sensitivity for detecting note onsets (0.0–1.0). "
161
+ "Lower = more notes detected, higher = only confident onsets.",
162
+ ),
163
+ frame_threshold: float = Form(
164
+ 0.3,
165
+ description="Minimum frame-level activation to sustain a note (0.0–1.0).",
166
+ ),
167
+ min_note_length: float = Form(
168
+ 0.058,
169
+ description="Minimum note duration in seconds. Shorter notes are filtered out.",
170
+ ),
171
+ min_frequency: Optional[float] = Form(
172
+ None,
173
+ description="Lowest frequency to transcribe in Hz (e.g. 80 for bass guitar). "
174
+ "Leave empty for no lower limit.",
175
+ ),
176
+ max_frequency: Optional[float] = Form(
177
+ None,
178
+ description="Highest frequency to transcribe in Hz (e.g. 2000 for voice). "
179
+ "Leave empty for no upper limit.",
180
+ ),
181
+ include_pitch_bends: bool = Form(
182
+ True,
183
+ description="Whether to detect and return sub-semitone pitch bend data.",
184
+ ),
185
+ multiple_pitch_bends: bool = Form(
186
+ False,
187
+ description="Allow multiple simultaneous pitch bends (polyphonic pitch bend). "
188
+ "Set True for instruments like guitar; False for monophonic sources.",
189
+ ),
190
+ melodia_trick: bool = Form(
191
+ True,
192
+ description="Apply the Melodia post-processing trick to reduce false positives "
193
+ "on sustained notes.",
194
+ ),
195
+ ):
196
+ """
197
+ Transcribe an audio file to MIDI notes.
198
+
199
+ Returns JSON with all detected notes (pitch, timing, velocity, pitch-bend)
200
+ plus a base64-encoded .mid file for direct download or import.
201
+
202
+ **Frontend usage:**
203
+ 1. POST the audio file + settings as multipart/form-data.
204
+ 2. Parse `notes` array directly into your piano-roll note objects.
205
+ 3. Optionally decode `midi_base64` and offer a "Download MIDI" button.
206
+ """
207
+
208
+ # ── Validate file extension ───────────────────────────────────────────────
209
+ filename = audio.filename or "audio"
210
+ ext = Path(filename).suffix.lower()
211
+ if ext not in SUPPORTED_EXTENSIONS:
212
+ raise HTTPException(
213
+ status_code=415,
214
+ detail=f"Unsupported file type '{ext}'. "
215
+ f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
216
+ )
217
+
218
+ # ── Read and size-check ───────────────────────────────────────────────────
219
+ audio_bytes = await audio.read()
220
+ size_mb = len(audio_bytes) / (1024 * 1024)
221
+ if size_mb > MAX_FILE_SIZE_MB:
222
+ raise HTTPException(
223
+ status_code=413,
224
+ detail=f"File too large ({size_mb:.1f} MB). Maximum is {MAX_FILE_SIZE_MB} MB.",
225
+ )
226
+
227
+ logger.info(f"Received '{filename}' ({size_mb:.2f} MB), running transcription…")
228
+
229
+ # ── Write to a temp file (basic-pitch needs a file path) ─────────────────
230
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
231
+ tmp.write(audio_bytes)
232
+ tmp_path = tmp.name
233
+
234
+ try:
235
+ # ── Validate & clamp user settings ───────────────────────────────────
236
+ onset_threshold = _clamp(onset_threshold, 0.05, 0.95)
237
+ frame_threshold = _clamp(frame_threshold, 0.05, 0.95)
238
+ min_note_length = max(0.01, min_note_length)
239
+
240
+ # ── Run basic-pitch ───────────────────────────────────────────────────
241
+ _model_output, midi_data, note_events = predict(
242
+ tmp_path,
243
+ _MODEL, # pre-loaded — no cold start per request
244
+ onset_threshold=onset_threshold,
245
+ frame_threshold=frame_threshold,
246
+ minimum_note_length=min_note_length,
247
+ minimum_frequency=min_frequency,
248
+ maximum_frequency=max_frequency,
249
+ include_pitch_bends=include_pitch_bends,
250
+ multiple_pitch_bends=multiple_pitch_bends,
251
+ melodia_trick=melodia_trick,
252
+ )
253
+
254
+ except Exception as exc:
255
+ logger.exception("Transcription failed")
256
+ raise HTTPException(status_code=500, detail=f"Transcription error: {exc}")
257
+ finally:
258
+ os.unlink(tmp_path) # always clean up the temp file
259
+
260
+ # ── Build response ────────────────────────────────────────────────────────
261
+ notes = midi_to_note_events(midi_data)
262
+ midi_b64 = midi_to_base64(midi_data)
263
+
264
+ # Attempt to extract tempo (basic-pitch doesn't always set this)
265
+ try:
266
+ tempos = midi_data.get_tempo_changes()
267
+ tempo_bpm = float(tempos[1][0]) if len(tempos[1]) > 0 else None
268
+ except Exception:
269
+ tempo_bpm = None
270
+
271
+ result = TranscriptionResult(
272
+ note_count=len(notes),
273
+ duration_seconds=round(midi_data.get_end_time(), 3),
274
+ tempo_bpm=round(tempo_bpm, 2) if tempo_bpm else None,
275
+ notes=notes,
276
+ midi_base64=midi_b64,
277
+ settings={
278
+ "onset_threshold": onset_threshold,
279
+ "frame_threshold": frame_threshold,
280
+ "min_note_length": min_note_length,
281
+ "min_frequency": min_frequency,
282
+ "max_frequency": max_frequency,
283
+ "include_pitch_bends": include_pitch_bends,
284
+ "multiple_pitch_bends": multiple_pitch_bends,
285
+ "melodia_trick": melodia_trick,
286
+ },
287
+ )
288
+
289
+ logger.info(f"Transcription complete: {len(notes)} notes detected.")
290
+ return result
291
+
292
+
293
+ # ── Dev entrypoint ────────────────────────────────────────────────────────────
294
+ if __name__ == "__main__":
295
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NoteGrabber API — Python dependencies
2
+ # Install with: pip install -r requirements.txt
3
+
4
+ # Web framework
5
+ fastapi>=0.111.0
6
+ uvicorn[standard]>=0.29.0
7
+
8
+ # File upload support for FastAPI
9
+ python-multipart>=0.0.9
10
+
11
+ # The core audio-to-MIDI engine (same model NeuralNote uses internally)
12
+ # On Linux servers (most common deploy target) this installs TFLite by default.
13
+ # For best accuracy, also run: pip install tensorflow
14
+ basic-pitch>=0.4.0
15
+
16
+ # MIDI manipulation (used to build the response and serialize to base64)
17
+ pretty-midi>=0.2.10
18
+
19
+ # Optional: install TensorFlow for highest model accuracy
20
+ # tensorflow>=2.12.0