FIX: Đã fix block lồng + mất MIDI (v202608062330)
This commit is contained in:
+159
-28
@@ -2491,7 +2491,9 @@ const WaveformLane = ({
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
|
||||
|
||||
// Draw sub-tracks within section
|
||||
// Draw sub-tracks within section (khôi phục 19:45 — section item phải
|
||||
// vẽ lại các item chứa bên trong sau khi mở project; buffers đã được
|
||||
// nạp đầy đủ bởi loadAudioBuffersForTracks recursion 19:00)
|
||||
const subTracks = sec.tracks || [];
|
||||
const subTrackCount = Math.min(subTracks.length, 4);
|
||||
const subTrackHeight = (height - 20) / Math.max(1, subTrackCount);
|
||||
@@ -9005,7 +9007,10 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
duration_bars: s.duration / secondsPerBar,
|
||||
clip_start_offset_bars: 0.0,
|
||||
source_data: {
|
||||
referenced_section_id: s.sectionId || s.id
|
||||
// KHÔNG fallback s.id: section item thiếu sectionId (insert thiếu
|
||||
// field) → fallback = chính id item → TỰ TRỎ → block lồng + mất
|
||||
// nội dung. Chỉ dùng sectionId; undefined → deserialize block rỗng.
|
||||
referenced_section_id: s.sectionId
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9225,7 +9230,8 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
|
||||
bpm: parseFloat(bpmVal || 120),
|
||||
time_signature_numerator: 4,
|
||||
time_signature_denominator: 4,
|
||||
sample_rate: 44100
|
||||
sample_rate: 44100,
|
||||
zoom: window.__currentZoom || 1.0
|
||||
},
|
||||
main_session: {
|
||||
id: "main",
|
||||
@@ -9330,7 +9336,8 @@ const deserializeProjectFromSchema = (schemaObj) => {
|
||||
tracks: restoredTracks,
|
||||
sessionTabs: restoredSessionTabs,
|
||||
subTabs: restoredSubTabs,
|
||||
masteringSettings: _migrateMasteringSettings(schemaObj.mastering_settings)
|
||||
masteringSettings: _migrateMasteringSettings(schemaObj.mastering_settings),
|
||||
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11538,6 +11545,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
}()));
|
||||
|
||||
const [zoom, setZoom] = React.useState(1.0);
|
||||
// Đồng bộ zoom ra window ref — serializeProjectToSchema lưu vào metadata.zoom
|
||||
// (module-level không truy cập state) → restore/open khôi phục đúng zoom.
|
||||
React.useEffect(() => { window.__currentZoom = zoom; }, [zoom]);
|
||||
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||
const scrollOffsetRef = React.useRef(0);
|
||||
scrollOffsetRef.current = scrollOffset;
|
||||
@@ -14937,12 +14947,33 @@ const App = () => {
|
||||
}
|
||||
return { ...c, buffer: clipBuffer };
|
||||
}));
|
||||
// Section items: nạp CẢ audioclip bên trong section (trước đây bỏ sót →
|
||||
// audioclip trong section hiện "(audio chưa được tải)" sau save+open).
|
||||
const updatedSections = await Promise.all((t.sections || []).map(async s => {
|
||||
const updatedSTracks = await Promise.all((s.tracks || []).map(async st => {
|
||||
const updatedSClips = await Promise.all((st.clips || []).map(async c => {
|
||||
let clipBuffer = c.buffer;
|
||||
const targetFileId = c.serverFileId || st.serverFileId;
|
||||
if (targetFileId && !clipBuffer) {
|
||||
const result = await tryLoad(targetFileId);
|
||||
if (result) {
|
||||
clipBuffer = result.audioBuffer;
|
||||
hasLoadedAny = true;
|
||||
}
|
||||
}
|
||||
return { ...c, buffer: clipBuffer };
|
||||
}));
|
||||
return { ...st, clips: updatedSClips };
|
||||
}));
|
||||
return { ...s, tracks: updatedSTracks };
|
||||
}));
|
||||
if (trackBuffer && updatedClips.length === 0) {
|
||||
const clipId = `default_${t.id}`;
|
||||
return { ...t, buffer: trackBuffer, channelInfo: trackChannelInfo,
|
||||
clips: [{ id: clipId, buffer: trackBuffer, startTime: t.startTime || 0, name: t.name, speed: 1.0 }] };
|
||||
clips: [{ id: clipId, buffer: trackBuffer, startTime: t.startTime || 0, name: t.name, speed: 1.0 }],
|
||||
sections: updatedSections };
|
||||
}
|
||||
return { ...t, buffer: trackBuffer, channelInfo: trackChannelInfo, clips: updatedClips };
|
||||
return { ...t, buffer: trackBuffer, channelInfo: trackChannelInfo, clips: updatedClips, sections: updatedSections };
|
||||
}));
|
||||
if (hasLoadedAny) {
|
||||
setTracks(prev => {
|
||||
@@ -14964,10 +14995,21 @@ const App = () => {
|
||||
return { ...ut, clips: mergedClips };
|
||||
});
|
||||
});
|
||||
// Merge cho SESSION TABS: track của session tab có id TRÙNG track MAIN
|
||||
// (vd id 1) — match vào allLoadedTracks (main đứng trước) → session tab
|
||||
// Track 01 bị THAY bằng track MAIN (chứa SECTION_ITEM + không có midi)
|
||||
// → block lồng + mất MIDI. FIX: session tab CHỈ match với INNER TRACKS
|
||||
// của section items (đúng tầng), KHÔNG match track main top-level.
|
||||
const sectionInnerTracks = [];
|
||||
updatedTracks.forEach(function (ut) {
|
||||
(ut.sections || []).forEach(function (s) {
|
||||
(s.tracks || []).forEach(function (st) { sectionInnerTracks.push(st); });
|
||||
});
|
||||
});
|
||||
setSessionTabs(prev => prev.map(st => ({
|
||||
...st,
|
||||
tracks: (st.tracks || []).map(t => {
|
||||
const found = updatedTracks.find(u => u.id === t.id);
|
||||
const found = sectionInnerTracks.find(u => u.id === t.id);
|
||||
return found || t;
|
||||
})
|
||||
})));
|
||||
@@ -15022,6 +15064,7 @@ const App = () => {
|
||||
restoredBpm = result.bpm;
|
||||
restoredSessionTabs = result.sessionTabs;
|
||||
restoredSubTabs = result.subTabs;
|
||||
if (result.zoom && setZoom) setZoom(result.zoom);
|
||||
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
|
||||
} else {
|
||||
restoredTracks = (parsed.tracks || []).map(function(t) {
|
||||
@@ -15040,6 +15083,18 @@ const App = () => {
|
||||
localStorage.setItem('sonic_project_id', lastId);
|
||||
setSessionTabs(restoredSessionTabs);
|
||||
setSubTabs(restoredSubTabs);
|
||||
// DIAG section-tab block: log tracks + sections của section tab sau restore
|
||||
try {
|
||||
(restoredSessionTabs || []).forEach(function(st) {
|
||||
var secCount = 0, midiCount = 0, clipCount = 0;
|
||||
(st.tracks || []).forEach(function(tr) {
|
||||
secCount += (tr.sections || []).length;
|
||||
midiCount += (tr.midiItems || []).length;
|
||||
clipCount += (tr.clips || []).length;
|
||||
});
|
||||
console.log('[Restore] sessionTab', st.id, 'sectionId', st.sectionId, 'tracks', (st.tracks || []).length, 'sections', secCount, 'midi', midiCount, 'clips', clipCount);
|
||||
});
|
||||
} catch (e) {}
|
||||
var restoredItemCount = 0;
|
||||
restoredTracks.forEach(function(rt) {
|
||||
if (rt.clips) restoredItemCount += rt.clips.length;
|
||||
@@ -16420,14 +16475,48 @@ const App = () => {
|
||||
showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!', 'success');
|
||||
};
|
||||
|
||||
const handleSaveSectionTab = (tabId) => {
|
||||
const handleSaveSectionTab = async (tabId) => {
|
||||
const tab = sessionTabs.find(s => s.id === tabId);
|
||||
if (!tab) return;
|
||||
const bpmVal = parseInt(bpm) || 120;
|
||||
const secondsPerBeat = 60.0 / bpmVal;
|
||||
const secondsPerBar = secondsPerBeat * 4;
|
||||
|
||||
const contentTracks = tab.tracks ? tab.tracks.filter(tr => tr.clips?.length > 0 || tr.midiItems?.length > 0) : [];
|
||||
// Upload buffer-only clips (chưa có serverFileId — upload sớm thất bại/
|
||||
// race) TRƯỚC khi ghi vào section item → serialized AUDIO_ITEM có file →
|
||||
// reload không mất audioclip.
|
||||
const ensureClipsUploaded = async (tracksArr) => {
|
||||
for (const tr of tracksArr || []) {
|
||||
for (const c of (tr.clips || [])) {
|
||||
if (!c.serverFileId && c.buffer && c.buffer.duration > 0.05) {
|
||||
try {
|
||||
const blob = encodeWavBlob(c.buffer);
|
||||
const up = await uploadToServer(new File([blob], (c.name || 'clip').replace(/\.[^.]+$/, '') + '.wav', { type: 'audio/wav' }), tr.id);
|
||||
if (up && up.file_id) { c.serverFileId = up.file_id; }
|
||||
} catch (err) { console.warn('section clip upload failed:', err); }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Gồm CẢ track buffer-only (audioclip vừa chèn, upload chưa tạo clip —
|
||||
// race: save trước khi upload xong → trước đây bị LỌC mất → audioclip
|
||||
// không lưu). Buffer-only → tạo default clip để serialize bắt được item.
|
||||
// LỌC BỎ section item TỰ TRỎ (sectionId/section item trỏ về CHÍNH section
|
||||
// đang lưu — insertSectionAtPlayhead tạo item thiếu sectionId → fallback
|
||||
// s.id → block lồng + ghi đè sectionStore → mất track MIDI).
|
||||
const contentTracks = tab.tracks ? tab.tracks
|
||||
.filter(tr => tr.clips?.length > 0 || tr.midiItems?.length > 0 || !!tr.buffer)
|
||||
.map(tr => {
|
||||
const nextTr = { ...tr };
|
||||
if (nextTr.sections && nextTr.sections.length > 0) {
|
||||
nextTr.sections = nextTr.sections.filter(s => (s.sectionId || s.id) !== tab.sectionId);
|
||||
}
|
||||
if (tr.buffer && (!tr.clips || tr.clips.length === 0)) {
|
||||
return { ...nextTr, clips: [{ id: 'default_' + tr.id, buffer: tr.buffer, startTime: tr.startTime || 0, name: tr.name, speed: 1.0 }] };
|
||||
}
|
||||
return nextTr;
|
||||
}) : [];
|
||||
let maxEndTime = 0;
|
||||
(contentTracks || []).forEach(tr => {
|
||||
(tr.clips || []).forEach(c => {
|
||||
@@ -16441,6 +16530,9 @@ const App = () => {
|
||||
});
|
||||
const durationSec = Math.max(maxEndTime, 4 * secondsPerBar);
|
||||
|
||||
// Đảm bảo mọi clip có serverFileId (upload buffer-only trước khi lưu)
|
||||
await ensureClipsUploaded(contentTracks);
|
||||
|
||||
setTracks(prev => prev.map(t => {
|
||||
if (!t.sections || t.sections.length === 0) return t;
|
||||
return {
|
||||
@@ -16473,18 +16565,25 @@ const App = () => {
|
||||
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
|
||||
const tabId = 'session_' + Date.now();
|
||||
const tabName = section.name || 'Section';
|
||||
const clonedTracks = section.tracks ? (JSON.parse(JSON.stringify(section.tracks))).map(t => ({
|
||||
...t,
|
||||
clips: [],
|
||||
sections: [],
|
||||
markers: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
instrumentId: t.instrumentId || null,
|
||||
instrumentProgram: t.instrumentProgram,
|
||||
instrumentName: t.instrumentName || null,
|
||||
_isSectionClone: true
|
||||
})) : tracks.filter(t => t.id === trackId).map(t => ({
|
||||
const clonedTracks = section.tracks ? section.tracks.map(t => {
|
||||
// Giữ CLIPS + gắn lại buffer THẬT (JSON.parse(JSON.stringify()) DROP
|
||||
// AudioBuffer → audioclip mất khỏi section editor). Trước đây clips:[]
|
||||
// → section item mở bằng nhấp đôi không chứa audioclip.
|
||||
const base = JSON.parse(JSON.stringify(t));
|
||||
const srcClips = (t.clips || []).map(c => ({ ...c, buffer: c.buffer || null }));
|
||||
return {
|
||||
...base,
|
||||
clips: srcClips,
|
||||
sections: [],
|
||||
markers: [],
|
||||
isArmed: false,
|
||||
monitoringEnabled: true,
|
||||
instrumentId: t.instrumentId || null,
|
||||
instrumentProgram: t.instrumentProgram,
|
||||
instrumentName: t.instrumentName || null,
|
||||
_isSectionClone: true
|
||||
};
|
||||
}) : tracks.filter(t => t.id === trackId).map(t => ({
|
||||
...t,
|
||||
clips: [],
|
||||
sections: [],
|
||||
@@ -17150,9 +17249,11 @@ const App = () => {
|
||||
}
|
||||
|
||||
// No item clicked under cursor -> Attempt to delete the track itself
|
||||
const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer;
|
||||
const hasMidi = track.midiItems && track.midiItems.length > 0;
|
||||
const hasSections = track.sections && track.sections.length > 0;
|
||||
// Item LỖI/rỗng (clip không buffer — khung viền "audio chưa được tải",
|
||||
// midi không notes, section không nội dung) KHÔNG chặn xoá track.
|
||||
const hasClips = (track.clips && track.clips.some(c => c.buffer)) || !!track.buffer;
|
||||
const hasMidi = track.midiItems && track.midiItems.some(m => (m.notes && m.notes.length > 0));
|
||||
const hasSections = track.sections && track.sections.some(s => (s.tracks && s.tracks.some(st => (st.clips && st.clips.some(c => c.buffer)) || (st.midiItems && st.midiItems.some(m => m.notes && m.notes.length > 0)))));
|
||||
const isTrackEmpty = !hasClips && !hasMidi && !hasSections;
|
||||
|
||||
if (!isTrackEmpty) {
|
||||
@@ -17802,6 +17903,31 @@ const App = () => {
|
||||
window.showToast = showToast;
|
||||
|
||||
// ── Server-side upload ──
|
||||
// WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có
|
||||
// serverFileId — upload sớm thất bại/race → clip mất sau reload).
|
||||
const encodeWavBlob = (audioBuffer) => {
|
||||
const numCh = Math.max(1, audioBuffer.numberOfChannels || 1);
|
||||
const sr = audioBuffer.sampleRate || 44100;
|
||||
const len = audioBuffer.length;
|
||||
const interleaved = new Float32Array(len * numCh);
|
||||
for (let ch = 0; ch < numCh; ch++) {
|
||||
const data = audioBuffer.getChannelData(ch);
|
||||
for (let i = 0; i < len; i++) interleaved[i * numCh + ch] = data[i];
|
||||
}
|
||||
const buffer = new ArrayBuffer(44 + interleaved.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
const writeStr = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); };
|
||||
writeStr(0, 'RIFF'); view.setUint32(4, 36 + interleaved.length * 2, true); writeStr(8, 'WAVE');
|
||||
writeStr(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numCh, true); view.setUint32(24, sr, true);
|
||||
view.setUint32(28, sr * numCh * 2, true); view.setUint16(32, numCh * 2, true); view.setUint16(34, 16, true);
|
||||
writeStr(36, 'data'); view.setUint32(40, interleaved.length * 2, true);
|
||||
for (let i = 0; i < interleaved.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, interleaved[i]));
|
||||
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
};
|
||||
const uploadToServer = async (file, trackId) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -18083,6 +18209,10 @@ const App = () => {
|
||||
const _anySubPlaying = subTabsRef.current.some(s => s.isPlaying);
|
||||
const activeSub = subTabsRef.current.find(s => s.id === activeTabRef.current);
|
||||
const isPianoRoll = activeSub && activeSub.type === 'PIANO_ROLL';
|
||||
// SECTION-TAB cũng chỉ rebuild khi NaN (20:00): audioclip mất/rest tự
|
||||
// nhiên → im lặng > 750ms là BÌNH THƯỜNG — pk<0.001 → recovery hủy play +
|
||||
// playhead về đầu track (đúng lỗi user: "playhead về đầu + không play").
|
||||
const isSectionTab = activeTabRef.current && activeTabRef.current.startsWith('session_');
|
||||
if ((isPlaying || _anySubPlaying) && masterBus && masterBus.analyser && (isPianoRoll || activeSourcesRef.current.length > 0)) {
|
||||
try {
|
||||
// "Đáng lẽ đang có âm" — quyết định watchdog có được rebuild không:
|
||||
@@ -18124,7 +18254,7 @@ const App = () => {
|
||||
// pk<0.001 (im lặng) KHÔNG trigger cho piano roll — rests tự nhiên
|
||||
// giữa các note > 750ms là BÌNH THƯỜNG → false-positive = recovery
|
||||
// hủy play + restart notes (glitch) — đúng chuỗi log recovery trước.
|
||||
if ((isPianoRoll ? nanOut : (pk < 0.001 || nanOut))) {
|
||||
if ((isPianoRoll || isSectionTab ? nanOut : (pk < 0.001 || nanOut))) {
|
||||
masterSilenceFramesRef.current++;
|
||||
const sinceRebuild = performance.now() - (lastMasterRebuildTimeRef.current || 0);
|
||||
// NaN = chain CHẾT chắc chắn → rebuild NGAY (3 frame ≈ 50ms).
|
||||
@@ -20158,9 +20288,9 @@ const App = () => {
|
||||
const track = trackList.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
|
||||
const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer;
|
||||
const hasMidi = track.midiItems && track.midiItems.length > 0;
|
||||
const hasSections = track.sections && track.sections.length > 0;
|
||||
const hasClips = (track.clips && track.clips.some(c => c.buffer)) || !!track.buffer;
|
||||
const hasMidi = track.midiItems && track.midiItems.some(m => (m.notes && m.notes.length > 0));
|
||||
const hasSections = track.sections && track.sections.some(s => (s.tracks && s.tracks.some(st => (st.clips && st.clips.some(c => c.buffer)) || (st.midiItems && st.midiItems.some(m => m.notes && m.notes.length > 0)))));
|
||||
const isTrackEmpty = !hasClips && !hasMidi && !hasSections;
|
||||
|
||||
if (!isTrackEmpty) {
|
||||
@@ -24542,6 +24672,7 @@ STRICT CONSTRAINTS:
|
||||
restoredBpm = result.bpm;
|
||||
restoredSessionTabs = result.sessionTabs;
|
||||
restoredSubTabs = result.subTabs;
|
||||
if (result.zoom && setZoom) setZoom(result.zoom);
|
||||
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
|
||||
} else {
|
||||
restoredTracks = (parsed.tracks || []).map(function (t) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608061830" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608062330" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user