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
+67 -148
View File
@@ -10482,53 +10482,27 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
onClick: () => {
const ppq = 480;
const bpmNum = parseInt(bpm) || 120;
const ticksPerBeat = ppq;
const events = [];
(notes || []).forEach(n => {
const startTick = Math.round((n.start_beat || 0) * ticksPerBeat);
const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat);
const pitch = n.pitch || 60;
const vel = Math.round((n.velocity || 0.8) * 127);
events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
});
events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1));
const writeVLQ = (bytes, v) => {
let val = Math.max(0, v);
const buf = [];
buf.push(val & 0x7F);
while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); }
for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]);
onClick: async () => {
if (!notes || !notes.length) { showToast('Chưa có nốt nhạc để xuất MIDI', 'warning'); return; }
const payload = {
bpm: parseFloat(bpm) || 120,
ppq: 480,
tracks: [{
name: st.label || 'Track',
notes: (notes || []).map(n => ({
pitch: n.pitch || 60,
start_beat: n.start_beat || 0,
duration_beats: n.duration_beats || 1,
velocity: n.velocity != null ? n.velocity : 0.8
}))
}]
};
const trackBytes = [];
let lastTick = 0;
events.forEach(ev => {
const delta = Math.max(0, ev.tick - lastTick);
writeVLQ(trackBytes, delta);
trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity);
lastTick = ev.tick;
});
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
const trackData = [0x4D, 0x54, 0x72, 0x6B];
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF];
const all = header.concat(trackData);
const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (st.label || 'midi') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('Đã xuất file MIDI!', 'success');
try {
await serverDownloadMidi(payload, (st.label || 'midi') + '.mid');
showToast('Đã xuất file MIDI!', 'success');
} catch (err) {
showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error');
}
},
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"), React.createElement("button", {
@@ -26056,117 +26030,62 @@ const App = () => {
};
// ── MIDI Export: tạo file MIDI từ tất cả tracks ──
const triggerMidiExport = () => {
// ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ──
// Blob download bị WebView2 chặn sau khi chuyển standalone → POST
// /api/v1/export/midi (server sinh file .mid) + GET download (network
// download, Content-Disposition attachment — pattern đã chạy ổn định).
// velocity gửi dạng 0..1 — backend scale sang 1..127 (sửa bug *100 cũ).
function serverDownloadMidi(payload, filename) {
return fetch(`${API_AUDIO}/export/midi`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(async res => {
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.detail || ('HTTP ' + res.status));
}
return res.json();
}).then(data => {
const a = document.createElement('a');
a.href = `${API_BASE_URL}${data.download_url}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return data;
});
}
const triggerMidiExport = async () => {
const bpmNum = parseFloat(bpm) || 120;
const ppq = 480; // Pulses Per Quarter Note
const ticksPerBeat = ppq;
const beatDuration = 60 / bpmNum;
// Build MIDI tracks
let midiTracks = [];
let currentTrackNum = 0;
const midiTracks = [];
tracks.forEach(track => {
currentTrackNum++;
const events = [];
let hasNotes = false;
// Collect MIDI events from midiItems
const midiItems = track.midiItems || [];
midiItems.forEach(item => {
const notes = item.notes || [];
notes.forEach(note => {
hasNotes = true;
const startTick = Math.round(note.startBeat * ticksPerBeat);
const durTick = Math.round(note.durationBeats * ticksPerBeat);
const velocity = Math.round((note.velocity || 0.8) * 100);
const pitch = note.pitch || 60;
events.push({ tick: startTick, type: 'note_on', pitch, velocity });
events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 });
const notes = [];
(track.midiItems || []).forEach(item => {
(item.notes || []).forEach(note => {
notes.push({
pitch: note.pitch || 60,
start_beat: note.startBeat || note.start_beat || 0,
duration_beats: note.durationBeats || note.duration_beats || 1,
velocity: note.velocity != null ? note.velocity : 0.8
});
});
});
// If track has audio buffer but no MIDI, create a "rest" track with one silent note
if (!hasNotes && track.buffer) {
const durSec = track.buffer.duration;
const durBeats = durSec / beatDuration;
const durTicks = Math.round(durBeats * ticksPerBeat);
// Use a C-2 (pitch 0 = rest note) indicator
events.push({ tick: 0, type: 'note_on', pitch: 0, velocity: 1 });
events.push({ tick: durTicks, type: 'note_off', pitch: 0, velocity: 0 });
}
if (events.length === 0 && !track.buffer) return; // Skip empty tracks
// Sort events by tick
events.sort((a, b) => a.tick - b.tick);
// MIDI track header bytes
const trackBytes = [];
// Track name
const nameStr = (track.name || ('Track ' + currentTrackNum)).slice(0, 255);
trackBytes.push(0xFF, 0x03, nameStr.length);
for (let i = 0; i < nameStr.length; i++) trackBytes.push(nameStr.charCodeAt(i));
// End of track marker will be calculated later
let lastTick = 0;
events.forEach(ev => {
const delta = ev.tick - lastTick;
lastTick = ev.tick;
// Delta time as variable-length quantity
writeVLQ(trackBytes, delta);
if (ev.type === 'note_on') {
trackBytes.push(0x90, ev.pitch, ev.velocity);
} else {
trackBytes.push(0x80, ev.pitch, 0);
}
});
// End of track
writeVLQ(trackBytes, 0);
trackBytes.push(0xFF, 0x2F, 0x00);
// Track chunk: "MTrk" + length + data
const trackData = [0x4D, 0x54, 0x72, 0x6B]; // "MTrk"
const len = trackBytes.length;
trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF);
trackData.push(...trackBytes);
midiTracks.push(trackData);
// bỏ track không có nốt MIDI (trước đây chèn "rest note" pitch 0)
if (notes.length) midiTracks.push({ name: track.name || 'Track', notes });
});
if (midiTracks.length === 0) {
if (!midiTracks.length) {
showToast("Không có dữ liệu MIDI nào để xuất.", "warning");
return;
}
// Header: "MThd" + length(6) + format(1) + tracks + division
const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, (midiTracks.length >> 8) & 0xFF, midiTracks.length & 0xFF, (ppq >> 8) & 0xFF, ppq & 0xFF];
const allBytes = header.concat(...midiTracks.flat());
const uint8 = new Uint8Array(allBytes);
const blob = new Blob([uint8], { type: 'audio/midi' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (projectName || 'Project') + '.mid';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
};
function writeVLQ(bytes, value) {
if (value < 0) value = 0;
const buf = [];
buf.push(value & 0x7F);
while (value > 0x7F) {
value >>= 7;
buf.push(0x80 | (value & 0x7F));
try {
await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid');
showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success");
} catch (err) {
showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error');
}
buf.reverse();
buf.forEach(b => bytes.push(b));
}
};
// ── Insert Track Below Selected ──
const insertTrackBelow = () => {
File diff suppressed because one or more lines are too long