fomext commited on
Commit
24e6a09
Β·
verified Β·
1 Parent(s): bc7bf7c

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +42 -0
  2. app.py +521 -0
  3. daw_companion.py +224 -0
  4. requirements.txt +30 -0
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # Voice-to-MIDI Β· Hugging Face Spaces Dockerfile
3
+ # Base: official Python slim – keeps image small for faster cold starts
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+ FROM python:3.10-slim
6
+
7
+ # HF Spaces runs as a non-root user; create it early
8
+ RUN useradd -m -u 1000 appuser
9
+
10
+ WORKDIR /app
11
+
12
+ # System deps: ffmpeg for broad audio codec support, libsndfile for soundfile
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ ffmpeg \
15
+ libsndfile1 \
16
+ libgomp1 \
17
+ git \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy and install Python deps as root (writes to site-packages)
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir --upgrade pip \
23
+ && pip install --no-cache-dir -r requirements.txt
24
+
25
+ # Copy application code
26
+ COPY app.py .
27
+
28
+ # Switch to non-root user (required by HF Spaces)
29
+ USER appuser
30
+
31
+ # HF Spaces expects the service on port 7860
32
+ EXPOSE 7860
33
+
34
+ # Pre-warm the Basic Pitch model on container start so the first request
35
+ # doesn't pay the cold-start cost. The one-worker uvicorn picks it up.
36
+ ENV PYTHONUNBUFFERED=1
37
+ ENV OMP_NUM_THREADS=2
38
+ ENV TF_NUM_INTEROP_THREADS=2
39
+ ENV TF_NUM_INTRAOP_THREADS=2
40
+
41
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", \
42
+ "--workers", "1", "--log-level", "info"]
app.py ADDED
@@ -0,0 +1,521 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Voice-to-MIDI FastAPI Service
3
+ Powered by Spotify Basic Pitch
4
+ Optimized for Hugging Face Spaces CPU deployment
5
+ """
6
+
7
+ import os
8
+ import uuid
9
+ import time
10
+ import asyncio
11
+ import logging
12
+ import tempfile
13
+ import threading
14
+ from pathlib import Path
15
+ from typing import Optional
16
+ from contextlib import asynccontextmanager
17
+ from collections import defaultdict
18
+
19
+ import numpy as np
20
+ import mido
21
+ import soundfile as sf
22
+ import librosa
23
+ import uvicorn
24
+ from fastapi import FastAPI, File, UploadFile, Form, HTTPException, BackgroundTasks
25
+ from fastapi.responses import FileResponse, JSONResponse
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+
28
+ # ─────────────────────────────────────────────
29
+ # Logging
30
+ # ─────────────────────────────────────────────
31
+ logging.basicConfig(
32
+ level=logging.INFO,
33
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
34
+ )
35
+ log = logging.getLogger("voice-to-midi")
36
+
37
+ # ─────────────────────────────────────────────
38
+ # Constants
39
+ # ─────────────────────────────────────────────
40
+ SUPPORTED_TYPES = {
41
+ "audio/wav", "audio/x-wav", "audio/mpeg", "audio/mp3",
42
+ "audio/ogg", "audio/x-m4a", "audio/mp4", "audio/aac",
43
+ "audio/flac", "application/octet-stream",
44
+ }
45
+ SUPPORTED_EXTENSIONS = {".wav", ".mp3", ".ogg", ".m4a", ".flac", ".aac"}
46
+ OUTPUT_DIR = Path(tempfile.gettempdir()) / "voice_midi_outputs"
47
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
48
+ MAX_FILE_AGE_SECONDS = 1800 # 30 min – auto-cleanup
49
+ MAX_UPLOAD_MB = 50
50
+ MIDI_NOTE_MIN = 21 # A0
51
+ MIDI_NOTE_MAX = 108 # C8
52
+ DEFAULT_SAMPLE_RATE = 22050
53
+
54
+ # ─────────────────────────────────────────────
55
+ # Global model state (loaded once at startup)
56
+ # ─────────────────────────────────────────────
57
+ _model_lock = threading.Lock()
58
+ _model_loaded = False
59
+
60
+
61
+ def _ensure_model_loaded() -> None:
62
+ """Import basic_pitch and warm up TensorFlow graph on first call."""
63
+ global _model_loaded
64
+ if _model_loaded:
65
+ return
66
+ with _model_lock:
67
+ if _model_loaded:
68
+ return
69
+ log.info("Loading Basic Pitch model – this happens once at cold start …")
70
+ # Importing triggers TF/tflite graph compilation
71
+ from basic_pitch.inference import predict # noqa: F401 (warm up)
72
+ _model_loaded = True
73
+ log.info("Basic Pitch model ready.")
74
+
75
+
76
+ # ─────────────────────────────────────────────
77
+ # App lifespan (startup / shutdown)
78
+ # ─────────────────────────────────────────────
79
+ @asynccontextmanager
80
+ async def lifespan(app: FastAPI):
81
+ # Warm the model in a thread so the event-loop isn't blocked
82
+ loop = asyncio.get_event_loop()
83
+ await loop.run_in_executor(None, _ensure_model_loaded)
84
+ # Schedule background cleanup
85
+ cleanup_task = asyncio.create_task(_periodic_cleanup())
86
+ yield
87
+ cleanup_task.cancel()
88
+
89
+
90
+ app = FastAPI(
91
+ title="Voice-to-MIDI",
92
+ description="Convert voice / humming / whistling to MIDI via Spotify Basic Pitch",
93
+ version="1.0.0",
94
+ lifespan=lifespan,
95
+ )
96
+
97
+ app.add_middleware(
98
+ CORSMiddleware,
99
+ allow_origins=["*"],
100
+ allow_methods=["*"],
101
+ allow_headers=["*"],
102
+ )
103
+
104
+
105
+ # ─────────────────────────────────────────────
106
+ # Background cleanup
107
+ # ─────────────────────────────────────────────
108
+ async def _periodic_cleanup():
109
+ """Delete MIDI files older than MAX_FILE_AGE_SECONDS every 10 minutes."""
110
+ while True:
111
+ await asyncio.sleep(600)
112
+ _cleanup_old_files()
113
+
114
+
115
+ def _cleanup_old_files():
116
+ now = time.time()
117
+ removed = 0
118
+ for f in OUTPUT_DIR.glob("*.mid"):
119
+ if now - f.stat().st_mtime > MAX_FILE_AGE_SECONDS:
120
+ try:
121
+ f.unlink()
122
+ removed += 1
123
+ except OSError:
124
+ pass
125
+ if removed:
126
+ log.info(f"Cleaned up {removed} old MIDI file(s).")
127
+
128
+
129
+ # ─────────────────────────────────────────────
130
+ # Utility helpers
131
+ # ─────────────────────────────────────────────
132
+
133
+ NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
134
+
135
+
136
+ def midi_to_note_name(midi_pitch: int) -> str:
137
+ """Convert MIDI pitch integer to scientific notation, e.g. 60 β†’ C4."""
138
+ octave = (midi_pitch // 12) - 1
139
+ name = NOTE_NAMES[midi_pitch % 12]
140
+ return f"{name}{octave}"
141
+
142
+
143
+ def load_audio(path: Path) -> tuple[np.ndarray, int]:
144
+ """Load any audio file to mono float32, resampled to DEFAULT_SAMPLE_RATE."""
145
+ try:
146
+ audio, sr = librosa.load(str(path), sr=DEFAULT_SAMPLE_RATE, mono=True)
147
+ return audio, sr
148
+ except Exception as exc:
149
+ raise HTTPException(status_code=422, detail=f"Cannot decode audio: {exc}")
150
+
151
+
152
+ def estimate_bpm(audio: np.ndarray, sr: int) -> Optional[float]:
153
+ """Use librosa onset/beat tracking to estimate tempo."""
154
+ try:
155
+ tempo, _ = librosa.beat.beat_track(y=audio, sr=sr)
156
+ val = float(np.atleast_1d(tempo)[0])
157
+ return round(val, 1) if 30 < val < 300 else None
158
+ except Exception:
159
+ return None
160
+
161
+
162
+ # ─────────────────────────────────────────────
163
+ # MIDI quality post-processing
164
+ # ─────────────────────────────────────────────
165
+
166
+ def clamp_pitch(pitch: int) -> int:
167
+ return max(MIDI_NOTE_MIN, min(MIDI_NOTE_MAX, pitch))
168
+
169
+
170
+ def remove_duplicate_overlaps(notes: list[dict]) -> list[dict]:
171
+ """
172
+ Remove notes that are exact duplicates or where one note completely
173
+ contains another at the same pitch.
174
+ """
175
+ # Sort by pitch then start time
176
+ notes = sorted(notes, key=lambda n: (n["pitch"], n["start"]))
177
+ result = []
178
+ by_pitch: dict[int, list[dict]] = defaultdict(list)
179
+ for n in notes:
180
+ by_pitch[n["pitch"]].append(n)
181
+
182
+ for pitch, group in by_pitch.items():
183
+ group = sorted(group, key=lambda n: n["start"])
184
+ merged = [group[0]]
185
+ for note in group[1:]:
186
+ prev = merged[-1]
187
+ # If new note starts before previous ends – merge or skip
188
+ if note["start"] < prev["end"]:
189
+ # Extend previous note if new one reaches further
190
+ if note["end"] > prev["end"]:
191
+ prev["end"] = note["end"]
192
+ prev["velocity"] = max(prev["velocity"], note["velocity"])
193
+ # else: new note is fully contained – skip it
194
+ else:
195
+ merged.append(note)
196
+ result.extend(merged)
197
+
198
+ return sorted(result, key=lambda n: n["start"])
199
+
200
+
201
+ def merge_tiny_notes(notes: list[dict], min_ms: float = 40) -> list[dict]:
202
+ """Drop notes shorter than min_ms milliseconds."""
203
+ min_sec = min_ms / 1000.0
204
+ kept = []
205
+ for n in notes:
206
+ if (n["end"] - n["start"]) >= min_sec:
207
+ kept.append(n)
208
+ return kept
209
+
210
+
211
+ def quantize_notes(notes: list[dict], bpm: float, subdivisions: int = 16) -> list[dict]:
212
+ """Snap note start/end times to the nearest subdivision grid."""
213
+ if bpm <= 0:
214
+ return notes
215
+ beat_sec = 60.0 / bpm
216
+ grid = beat_sec / (subdivisions / 4) # e.g. 16th note
217
+
218
+ def snap(t: float) -> float:
219
+ return round(round(t / grid) * grid, 4)
220
+
221
+ for n in notes:
222
+ n["start"] = snap(n["start"])
223
+ n["end"] = snap(n["end"])
224
+ if n["end"] <= n["start"]:
225
+ n["end"] = n["start"] + grid
226
+ return notes
227
+
228
+
229
+ def notes_to_midi(
230
+ notes: list[dict],
231
+ bpm: float,
232
+ instrument_name: str = "Acoustic Grand Piano",
233
+ ) -> mido.MidiFile:
234
+ """Convert list of note dicts to a mido MidiFile object."""
235
+ mid = mido.MidiFile(type=0, ticks_per_beat=480)
236
+ track = mido.MidiTrack()
237
+ mid.tracks.append(track)
238
+
239
+ # Tempo
240
+ tempo = mido.bpm2tempo(bpm)
241
+ track.append(mido.MetaMessage("set_tempo", tempo=tempo, time=0))
242
+
243
+ # Program change (General MIDI instrument)
244
+ gm_programs = {
245
+ "Acoustic Grand Piano": 0, "Electric Piano": 4,
246
+ "Violin": 40, "Flute": 73, "Synth Lead": 80,
247
+ }
248
+ program = gm_programs.get(instrument_name, 0)
249
+ track.append(mido.Message("program_change", program=program, time=0))
250
+
251
+ # Build flat event list: (abs_time_sec, type, pitch, velocity)
252
+ events = []
253
+ for n in notes:
254
+ events.append((n["start"], "note_on", n["pitch"], n["velocity"]))
255
+ events.append((n["end"], "note_off", n["pitch"], 0))
256
+
257
+ events.sort(key=lambda e: (e[0], 0 if e[1] == "note_off" else 1))
258
+
259
+ def sec_to_ticks(t: float) -> int:
260
+ return int(mido.second2tick(t, mid.ticks_per_beat, tempo))
261
+
262
+ prev_ticks = 0
263
+ for abs_sec, msg_type, pitch, vel in events:
264
+ abs_ticks = sec_to_ticks(abs_sec)
265
+ delta = max(0, abs_ticks - prev_ticks)
266
+ track.append(mido.Message(msg_type, note=pitch, velocity=vel, time=delta))
267
+ prev_ticks = abs_ticks
268
+
269
+ return mid
270
+
271
+
272
+ def group_chords(notes: list[dict], window_sec: float = 0.05) -> list[list[dict]]:
273
+ """Group notes that start within window_sec of each other into chords."""
274
+ if not notes:
275
+ return []
276
+ sorted_notes = sorted(notes, key=lambda n: n["start"])
277
+ groups = [[sorted_notes[0]]]
278
+ for note in sorted_notes[1:]:
279
+ if abs(note["start"] - groups[-1][0]["start"]) <= window_sec:
280
+ groups[-1].append(note)
281
+ else:
282
+ groups.append([note])
283
+ return groups
284
+
285
+
286
+ # ─────────────────────────────────────────────
287
+ # Core transcription logic
288
+ # ─────────────────────────────────────────────
289
+
290
+ def transcribe_audio(
291
+ audio: np.ndarray,
292
+ sr: int,
293
+ bpm_hint: Optional[float],
294
+ quantize: bool,
295
+ min_note_length_ms: float,
296
+ onset_sensitivity: float,
297
+ ) -> tuple[list[dict], float, Optional[float]]:
298
+ """
299
+ Run Basic Pitch inference and return:
300
+ - list of note dicts
301
+ - audio duration (seconds)
302
+ - detected bpm (or None)
303
+ """
304
+ from basic_pitch.inference import predict
305
+ from basic_pitch import ICASSP_2022_MODEL_PATH
306
+
307
+ duration = len(audio) / sr
308
+
309
+ # Write temp wav for basic_pitch (it expects a file path)
310
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
311
+ tmp_path = tmp.name
312
+ try:
313
+ sf.write(tmp_path, audio, sr)
314
+
315
+ model_output, midi_data, note_events = predict(
316
+ tmp_path,
317
+ onset_threshold=onset_sensitivity,
318
+ frame_threshold=0.3,
319
+ minimum_note_length=min_note_length_ms / 1000.0,
320
+ minimum_frequency=librosa.midi_to_hz(MIDI_NOTE_MIN),
321
+ maximum_frequency=librosa.midi_to_hz(MIDI_NOTE_MAX),
322
+ melodia_trick=True,
323
+ midi_tempo=bpm_hint or 120,
324
+ )
325
+ finally:
326
+ os.unlink(tmp_path)
327
+
328
+ # note_events: list of (start_time, end_time, pitch_midi, amplitude, pitch_bends)
329
+ notes = []
330
+ for start, end, pitch, amplitude, _ in note_events:
331
+ pitch = clamp_pitch(int(round(pitch)))
332
+ velocity = int(np.clip(amplitude * 127, 1, 127))
333
+ notes.append({
334
+ "pitch": pitch,
335
+ "note_name": midi_to_note_name(pitch),
336
+ "start": round(float(start), 4),
337
+ "end": round(float(end), 4),
338
+ "velocity": velocity,
339
+ "confidence": round(float(amplitude), 4),
340
+ })
341
+
342
+ # Post-process
343
+ notes = merge_tiny_notes(notes, min_ms=min_note_length_ms)
344
+ notes = remove_duplicate_overlaps(notes)
345
+
346
+ # BPM
347
+ detected_bpm = bpm_hint or estimate_bpm(audio, sr)
348
+ if quantize and detected_bpm:
349
+ notes = quantize_notes(notes, detected_bpm)
350
+
351
+ notes = sorted(notes, key=lambda n: n["start"])
352
+ return notes, duration, detected_bpm
353
+
354
+
355
+ # ─────────────────────────────────────────────
356
+ # Routes
357
+ # ─────────────────────────────────────────────
358
+
359
+ @app.get("/", summary="Health check")
360
+ async def root():
361
+ return {
362
+ "status": "online",
363
+ "service": "voice-to-midi",
364
+ "engine": "basic-pitch",
365
+ "model_ready": _model_loaded,
366
+ }
367
+
368
+
369
+ @app.get("/health")
370
+ async def health():
371
+ return {"status": "ok"}
372
+
373
+
374
+ @app.post("/transcribe", summary="Convert audio to MIDI")
375
+ async def transcribe(
376
+ background_tasks: BackgroundTasks,
377
+ file: UploadFile = File(..., description="Audio file (wav/mp3/ogg/m4a/flac)"),
378
+ bpm: Optional[float] = Form(None, description="Hint BPM (auto-detected if omitted)"),
379
+ quantize: bool = Form(False, description="Snap notes to nearest beat grid"),
380
+ instrument_name: str = Form("Acoustic Grand Piano"),
381
+ min_note_length_ms: float = Form(40.0, description="Minimum note duration in ms"),
382
+ onset_sensitivity: float = Form(0.5, description="Onset detection threshold 0–1"),
383
+ ):
384
+ # ── Validate file ──────────────────────────────────────────────────────
385
+ if not file.filename:
386
+ raise HTTPException(status_code=400, detail="No filename provided.")
387
+
388
+ suffix = Path(file.filename).suffix.lower()
389
+ if suffix not in SUPPORTED_EXTENSIONS:
390
+ raise HTTPException(
391
+ status_code=415,
392
+ detail=f"Unsupported file type '{suffix}'. Supported: {sorted(SUPPORTED_EXTENSIONS)}",
393
+ )
394
+
395
+ content_type = (file.content_type or "").split(";")[0].strip().lower()
396
+ if content_type and content_type not in SUPPORTED_TYPES:
397
+ log.warning(f"Unexpected content-type: {content_type} – proceeding anyway.")
398
+
399
+ onset_sensitivity = float(np.clip(onset_sensitivity, 0.1, 0.9))
400
+ min_note_length_ms = max(10.0, min(500.0, min_note_length_ms))
401
+
402
+ # ── Read & size-check upload ──────────────────────────────────────────���
403
+ raw = await file.read()
404
+ if len(raw) > MAX_UPLOAD_MB * 1_000_000:
405
+ raise HTTPException(status_code=413, detail=f"File exceeds {MAX_UPLOAD_MB} MB limit.")
406
+ if len(raw) < 1024:
407
+ raise HTTPException(status_code=400, detail="File too small – likely empty or corrupt.")
408
+
409
+ # ── Save to temp, load audio ───────────────────────────────────────────
410
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
411
+ tmp.write(raw)
412
+ tmp_input_path = Path(tmp.name)
413
+
414
+ try:
415
+ audio, sr = load_audio(tmp_input_path)
416
+ finally:
417
+ tmp_input_path.unlink(missing_ok=True)
418
+
419
+ if len(audio) / sr < 0.2:
420
+ raise HTTPException(status_code=422, detail="Audio too short (< 0.2 s).")
421
+
422
+ # ── Ensure model loaded ────────────────────────────────────────────────
423
+ _ensure_model_loaded()
424
+
425
+ # ── Run inference ──────────────────────────────────────────────────────
426
+ t0 = time.perf_counter()
427
+ try:
428
+ loop = asyncio.get_event_loop()
429
+ notes, duration, detected_bpm = await loop.run_in_executor(
430
+ None,
431
+ transcribe_audio,
432
+ audio, sr, bpm, quantize, min_note_length_ms, onset_sensitivity,
433
+ )
434
+ except HTTPException:
435
+ raise
436
+ except Exception as exc:
437
+ log.exception("Inference failed")
438
+ raise HTTPException(status_code=500, detail=f"Inference error: {exc}")
439
+
440
+ elapsed = time.perf_counter() - t0
441
+ log.info(f"Transcribed {duration:.1f}s audio β†’ {len(notes)} notes in {elapsed:.2f}s")
442
+
443
+ if not notes:
444
+ raise HTTPException(
445
+ status_code=422,
446
+ detail="No notes detected. Try adjusting onset_sensitivity or check audio quality.",
447
+ )
448
+
449
+ # ── Build MIDI ─────────────────────────────────────────────────────────
450
+ effective_bpm = detected_bpm or 120.0
451
+ midi_obj = notes_to_midi(notes, effective_bpm, instrument_name)
452
+ midi_id = uuid.uuid4().hex
453
+ midi_path = OUTPUT_DIR / f"{midi_id}.mid"
454
+ midi_obj.save(str(midi_path))
455
+
456
+ # ── Chord grouping meta ────────────────────────────────────────────────
457
+ chords = group_chords(notes)
458
+ chord_count = sum(1 for g in chords if len(g) > 1)
459
+
460
+ return JSONResponse({
461
+ "success": True,
462
+ "notes": notes,
463
+ "midi_url": f"/download/{midi_id}.mid",
464
+ "note_count": len(notes),
465
+ "duration": round(duration, 3),
466
+ "bpm": effective_bpm,
467
+ "bpm_source": "hint" if bpm else ("detected" if detected_bpm else "default"),
468
+ "chord_count": chord_count,
469
+ "processing_time_sec": round(elapsed, 3),
470
+ "quantized": quantize,
471
+ "instrument": instrument_name,
472
+ })
473
+
474
+
475
+ @app.get("/download/{filename}", summary="Download generated MIDI file")
476
+ async def download(filename: str):
477
+ # Security: disallow path traversal
478
+ if "/" in filename or "\\" in filename or ".." in filename:
479
+ raise HTTPException(status_code=400, detail="Invalid filename.")
480
+ if not filename.endswith(".mid"):
481
+ raise HTTPException(status_code=400, detail="Only .mid files available.")
482
+
483
+ path = OUTPUT_DIR / filename
484
+ if not path.exists():
485
+ raise HTTPException(status_code=404, detail="File not found or expired.")
486
+
487
+ return FileResponse(
488
+ path=str(path),
489
+ media_type="audio/midi",
490
+ filename=filename,
491
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
492
+ )
493
+
494
+
495
+ @app.get("/info", summary="Service info & supported formats")
496
+ async def info():
497
+ return {
498
+ "supported_formats": sorted(SUPPORTED_EXTENSIONS),
499
+ "max_upload_mb": MAX_UPLOAD_MB,
500
+ "midi_note_range": {"min": MIDI_NOTE_MIN, "max": MIDI_NOTE_MAX},
501
+ "default_sample_rate": DEFAULT_SAMPLE_RATE,
502
+ "file_expiry_minutes": MAX_FILE_AGE_SECONDS // 60,
503
+ "endpoints": [
504
+ {"path": "/", "method": "GET", "description": "Health check"},
505
+ {"path": "/transcribe", "method": "POST", "description": "Audio β†’ MIDI"},
506
+ {"path": "/download/{filename}", "method": "GET", "description": "MIDI download"},
507
+ ],
508
+ }
509
+
510
+
511
+ # ─────────────────────────────────────────────
512
+ # Entry point (local dev)
513
+ # ─────────────────────────────────────────────
514
+ if __name__ == "__main__":
515
+ uvicorn.run(
516
+ "app:app",
517
+ host="0.0.0.0",
518
+ port=7860,
519
+ workers=1, # Single worker on HF Spaces CPU; model is global
520
+ log_level="info",
521
+ )
daw_companion.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ daw_companion.py
4
+ ────────────────
5
+ Local DAW integration script for Voice-to-MIDI service.
6
+
7
+ Usage:
8
+ python daw_companion.py record # Record mic β†’ transcribe β†’ save MIDI
9
+ python daw_companion.py file audio.wav # Transcribe existing file β†’ save MIDI
10
+ python daw_companion.py watch ./inbox # Watch folder for new audio, auto-convert
11
+
12
+ Requirements:
13
+ pip install requests sounddevice scipy
14
+
15
+ Configure SERVICE_URL below to point at your Hugging Face Space.
16
+ """
17
+
18
+ import sys
19
+ import time
20
+ import os
21
+ import argparse
22
+ import tempfile
23
+ from pathlib import Path
24
+
25
+ import requests
26
+
27
+ # ─── Configuration ─────────────────────────────────────────────────────────────
28
+ SERVICE_URL = os.getenv(
29
+ "VOICE_MIDI_URL",
30
+ "https://YOUR-USERNAME-voice-to-midi.hf.space", # ← change this
31
+ )
32
+ OUTPUT_DIR = Path("./midi_output")
33
+ OUTPUT_DIR.mkdir(exist_ok=True)
34
+
35
+ DEFAULT_PARAMS = {
36
+ "bpm": None, # None = auto-detect
37
+ "quantize": False,
38
+ "instrument_name": "Acoustic Grand Piano",
39
+ "min_note_length_ms": 40,
40
+ "onset_sensitivity": 0.5,
41
+ }
42
+ # ───────────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def check_service():
46
+ try:
47
+ r = requests.get(f"{SERVICE_URL}/", timeout=10)
48
+ r.raise_for_status()
49
+ data = r.json()
50
+ print(f"βœ… Service online | engine: {data.get('engine')} | model_ready: {data.get('model_ready')}")
51
+ return True
52
+ except Exception as exc:
53
+ print(f"❌ Cannot reach service: {exc}")
54
+ return False
55
+
56
+
57
+ def transcribe_file(audio_path: Path, params: dict = None, output_name: str = None) -> Path | None:
58
+ """Send an audio file to the service and save the returned MIDI."""
59
+ params = {**DEFAULT_PARAMS, **(params or {})}
60
+
61
+ print(f"\nπŸ“€ Uploading {audio_path.name} ({audio_path.stat().st_size // 1024} KB) …")
62
+ t0 = time.perf_counter()
63
+
64
+ form_data = {k: str(v) for k, v in params.items() if v is not None}
65
+
66
+ with audio_path.open("rb") as fh:
67
+ resp = requests.post(
68
+ f"{SERVICE_URL}/transcribe",
69
+ files={"file": (audio_path.name, fh, "audio/wav")},
70
+ data=form_data,
71
+ timeout=120,
72
+ )
73
+
74
+ if resp.status_code != 200:
75
+ print(f"❌ Error {resp.status_code}: {resp.text}")
76
+ return None
77
+
78
+ result = resp.json()
79
+ elapsed = time.perf_counter() - t0
80
+
81
+ print(f"βœ… Transcribed in {elapsed:.1f}s")
82
+ print(f" Notes : {result['note_count']}")
83
+ print(f" Duration : {result['duration']:.2f}s")
84
+ print(f" BPM : {result['bpm']} ({result['bpm_source']})")
85
+ print(f" Chords : {result['chord_count']}")
86
+ print(f" Processing : {result['processing_time_sec']}s")
87
+
88
+ # Download MIDI
89
+ midi_url = f"{SERVICE_URL}{result['midi_url']}"
90
+ midi_resp = requests.get(midi_url, timeout=30)
91
+ midi_resp.raise_for_status()
92
+
93
+ stem = output_name or audio_path.stem
94
+ midi_path = OUTPUT_DIR / f"{stem}_{int(time.time())}.mid"
95
+ midi_path.write_bytes(midi_resp.content)
96
+ print(f"πŸ’Ύ Saved MIDI β†’ {midi_path}")
97
+
98
+ # Print note table
99
+ if result["notes"]:
100
+ print("\n Pitch Note Start End Vel Conf")
101
+ print(" ───── ──── ─────── ─────── ─── ────")
102
+ for n in result["notes"][:20]:
103
+ print(
104
+ f" {n['pitch']:>5} {n['note_name']:<4} "
105
+ f"{n['start']:>7.3f} {n['end']:>7.3f} "
106
+ f"{n['velocity']:>3} {n['confidence']:.2f}"
107
+ )
108
+ if len(result["notes"]) > 20:
109
+ print(f" … and {len(result['notes']) - 20} more notes")
110
+
111
+ return midi_path
112
+
113
+
114
+ def record_and_transcribe(duration_sec: float = 5.0, params: dict = None) -> Path | None:
115
+ """Record from default microphone and transcribe."""
116
+ try:
117
+ import sounddevice as sd
118
+ from scipy.io.wavfile import write as wav_write
119
+ import numpy as np
120
+ except ImportError:
121
+ print("❌ Install sounddevice and scipy: pip install sounddevice scipy")
122
+ return None
123
+
124
+ SR = 44100
125
+ print(f"\nπŸŽ™οΈ Recording for {duration_sec:.0f}s … (press Ctrl+C to stop early)")
126
+ try:
127
+ audio = sd.rec(int(duration_sec * SR), samplerate=SR, channels=1, dtype="float32")
128
+ sd.wait()
129
+ except KeyboardInterrupt:
130
+ sd.stop()
131
+ audio = audio[:sd.get_stream().time * SR] # trim
132
+ print(" (recording stopped early)")
133
+
134
+ # Write to temp wav
135
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
136
+ wav_path = Path(tmp.name)
137
+ wav_write(str(wav_path), SR, (audio * 32767).astype("int16"))
138
+
139
+ try:
140
+ return transcribe_file(wav_path, params=params, output_name="recording")
141
+ finally:
142
+ wav_path.unlink(missing_ok=True)
143
+
144
+
145
+ def watch_folder(folder: Path, params: dict = None):
146
+ """Watch a folder for new audio files and auto-transcribe them."""
147
+ print(f"\nπŸ‘€ Watching {folder.resolve()} for new audio files …")
148
+ print(" Drop .wav / .mp3 / .ogg files here. Press Ctrl+C to stop.\n")
149
+ seen = {f for f in folder.iterdir() if f.is_file()}
150
+ try:
151
+ while True:
152
+ time.sleep(1)
153
+ current = {f for f in folder.iterdir() if f.is_file()}
154
+ new_files = current - seen
155
+ seen = current
156
+ for f in sorted(new_files):
157
+ if f.suffix.lower() in {".wav", ".mp3", ".ogg", ".m4a", ".flac"}:
158
+ print(f"\nπŸ†• New file detected: {f.name}")
159
+ transcribe_file(f, params=params)
160
+ except KeyboardInterrupt:
161
+ print("\nπŸ‘‹ Stopped watching.")
162
+
163
+
164
+ # ─── CLI ───────────────────────────────────────────────────────────────────────
165
+
166
+ def main():
167
+ parser = argparse.ArgumentParser(description="Voice-to-MIDI DAW Companion")
168
+ sub = parser.add_subparsers(dest="cmd")
169
+
170
+ # record
171
+ rec = sub.add_parser("record", help="Record mic audio then transcribe")
172
+ rec.add_argument("--duration", type=float, default=5.0)
173
+ rec.add_argument("--bpm", type=float, default=None)
174
+ rec.add_argument("--quantize", action="store_true")
175
+
176
+ # file
177
+ fil = sub.add_parser("file", help="Transcribe an existing audio file")
178
+ fil.add_argument("path", type=Path)
179
+ fil.add_argument("--bpm", type=float, default=None)
180
+ fil.add_argument("--quantize", action="store_true")
181
+ fil.add_argument("--onset", type=float, default=0.5)
182
+
183
+ # watch
184
+ wat = sub.add_parser("watch", help="Watch folder for new audio files")
185
+ wat.add_argument("folder", type=Path)
186
+ wat.add_argument("--bpm", type=float, default=None)
187
+ wat.add_argument("--quantize", action="store_true")
188
+
189
+ # check
190
+ sub.add_parser("check", help="Check service health")
191
+
192
+ args = parser.parse_args()
193
+
194
+ if not check_service():
195
+ sys.exit(1)
196
+
197
+ if args.cmd == "check":
198
+ return
199
+
200
+ params = {}
201
+ if hasattr(args, "bpm") and args.bpm:
202
+ params["bpm"] = args.bpm
203
+ if hasattr(args, "quantize") and args.quantize:
204
+ params["quantize"] = True
205
+ if hasattr(args, "onset"):
206
+ params["onset_sensitivity"] = args.onset
207
+
208
+ if args.cmd == "record":
209
+ record_and_transcribe(duration_sec=args.duration, params=params)
210
+ elif args.cmd == "file":
211
+ if not args.path.exists():
212
+ print(f"❌ File not found: {args.path}")
213
+ sys.exit(1)
214
+ transcribe_file(args.path, params=params)
215
+ elif args.cmd == "watch":
216
+ if not args.folder.exists():
217
+ args.folder.mkdir(parents=True)
218
+ watch_folder(args.folder, params=params)
219
+ else:
220
+ parser.print_help()
221
+
222
+
223
+ if __name__ == "__main__":
224
+ main()
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─── Web framework ────────────────────────────────────────────────────────────
2
+ fastapi==0.111.0
3
+ uvicorn[standard]==0.29.0
4
+ python-multipart==0.0.9
5
+
6
+ # ─── Audio I/O ────────────────────────────────────────────────────────────────
7
+ librosa==0.10.2
8
+ soundfile==0.12.1
9
+ audioread==3.0.1
10
+
11
+ # ─── Spotify Basic Pitch ──────────────────────────────────────────────────────
12
+ # Pinned to stable CPU-friendly release; uses tflite-runtime under the hood
13
+ basic-pitch==0.3.3
14
+
15
+ # ─── MIDI ─────────────────────────────────────────────────────────────────────
16
+ mido==1.3.2
17
+
18
+ # ─── Numerics ─────────────────────────────────────────────────────────────────
19
+ numpy==1.26.4
20
+ scipy==1.13.0
21
+
22
+ # ─── TensorFlow Lite runtime (CPU, smaller than full TF) ──────────────────────
23
+ # basic-pitch will pull tflite-runtime automatically; pin here for reproducibility
24
+ tflite-runtime==2.14.0; platform_machine == "x86_64"
25
+ # Fallback for other arches (HF Spaces uses x86_64, so this is usually unused)
26
+ tensorflow-cpu==2.15.0; platform_machine != "x86_64"
27
+
28
+ # ─── HTTP client (for DAW companion script example) ───────────────────────────
29
+ httpx==0.27.0
30
+ requests==2.31.0