fix(export): MIDI export qua server endpoint thay blob download (WebView2 chặn) + velocity 1..127, bỏ rest-track
This commit is contained in:
+67
-148
@@ -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
Reference in New Issue
Block a user