fix(export): MIDI export qua server endpoint thay blob download (WebView2 chặn) + velocity 1..127, bỏ rest-track
This commit is contained in:
+84
-1
@@ -56,6 +56,21 @@ class ExportRequest(BaseModel):
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16
|
||||
|
||||
class MidiNoteModel(BaseModel):
|
||||
pitch: int = 60
|
||||
start_beat: float = 0.0
|
||||
duration_beats: float = 1.0
|
||||
velocity: float = 0.8 # 0..1
|
||||
|
||||
class MidiExportTrackModel(BaseModel):
|
||||
name: str = "Track"
|
||||
notes: List[MidiNoteModel] = []
|
||||
|
||||
class MidiExportRequest(BaseModel):
|
||||
bpm: float = 120.0
|
||||
ppq: int = 480
|
||||
tracks: List[MidiExportTrackModel] = []
|
||||
|
||||
class AIAnalysisRequest(BaseModel):
|
||||
file_id: str
|
||||
api_base_url: Optional[str] = None
|
||||
@@ -153,7 +168,8 @@ async def download_audio(file_id: str):
|
||||
path = _resolve_storage_path(file_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||
media = "audio/midi" if os.path.splitext(path)[1].lower() == ".mid" else "audio/wav"
|
||||
return FileResponse(path, media_type=media, filename=os.path.basename(path))
|
||||
|
||||
@router.get("/waveform/{file_id}")
|
||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||
@@ -226,6 +242,73 @@ async def export_audio(req: ExportRequest, current_user: Optional[dict] = Depend
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
# ── MIDI export (BIG_PHASE_3 Phase 1) ──
|
||||
# Sinh file .mid format 1 bằng stdlib thuần — trước đây frontend tự build bytes
|
||||
# rồi blob-download (bị WebView2 chặn sau khi chuyển standalone). Server sinh
|
||||
# file → GET /download/{file_id} (network download, pattern đã chạy ổn định).
|
||||
def _write_vlq(data: bytearray, value: int) -> None:
|
||||
buf = [value & 0x7F]
|
||||
while value > 0x7F:
|
||||
value >>= 7
|
||||
buf.append(0x80 | (value & 0x7F))
|
||||
for b in reversed(buf):
|
||||
data.append(b)
|
||||
|
||||
def _build_midi_file(req: MidiExportRequest) -> bytes:
|
||||
import struct
|
||||
tpb = max(1, req.ppq or 480)
|
||||
track_chunks = []
|
||||
for tr in req.tracks:
|
||||
events = []
|
||||
for n in tr.notes:
|
||||
if (n.duration_beats or 0) <= 0:
|
||||
continue
|
||||
pitch = max(0, min(127, int(round(n.pitch or 60))))
|
||||
vel = max(1, min(127, int(round((n.velocity if n.velocity is not None else 0.8) * 127))))
|
||||
start = max(0, int(round((n.start_beat or 0) * tpb)))
|
||||
end = start + max(1, int(round((n.duration_beats or 1) * tpb)))
|
||||
events.append((start, 0x90, pitch, vel))
|
||||
events.append((end, 0x80, pitch, 0))
|
||||
if not events:
|
||||
continue # bỏ track không có nốt (trước đây chèn "rest note" pitch 0)
|
||||
# note_off (0x80) trước note_on (0x90) cùng tick — tránh note kẹt
|
||||
events.sort(key=lambda e: (e[0], 0 if e[1] == 0x80 else 1))
|
||||
body = bytearray()
|
||||
name_bytes = (tr.name or "Track").encode("utf-8", "replace")[:255]
|
||||
body.append(0xFF); body.append(0x03); body.append(len(name_bytes)); body.extend(name_bytes)
|
||||
last = 0
|
||||
for tick, status, pitch, vel in events:
|
||||
_write_vlq(body, tick - last)
|
||||
last = tick
|
||||
body.append(status); body.append(pitch); body.append(vel)
|
||||
_write_vlq(body, 0)
|
||||
body.append(0xFF); body.append(0x2F); body.append(0x00)
|
||||
chunk = bytearray(b"MTrk")
|
||||
chunk += struct.pack(">I", len(body))
|
||||
chunk += body
|
||||
track_chunks.append(bytes(chunk))
|
||||
if not track_chunks:
|
||||
return b""
|
||||
header = bytearray(b"MThd")
|
||||
header += struct.pack(">IHHH", 6, 1, len(track_chunks), tpb)
|
||||
return bytes(header) + b"".join(track_chunks)
|
||||
|
||||
@router.post("/export/midi")
|
||||
async def export_midi(req: MidiExportRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
data = _build_midi_file(req)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Không có dữ liệu MIDI nào để xuất")
|
||||
file_id = "midi_export_%s.mid" % uuid.uuid4().hex[:12]
|
||||
with open(os.path.join(settings.PROCESSED_DIR, file_id), "wb") as f:
|
||||
f.write(data)
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"download_url": "/api/v1/audio/download/%s" % file_id,
|
||||
"bytes": len(data)
|
||||
}
|
||||
|
||||
@router.post("/ai-scan")
|
||||
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user