fix(export): MIDI export qua server endpoint thay blob download (WebView2 chặn) + velocity 1..127, bỏ rest-track

This commit is contained in:
2026-08-21 11:10:32 +07:00
parent d7756b08ab
commit 96da8b5ac0
7 changed files with 863 additions and 167 deletions
+84 -1
View File
@@ -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)):
"""