FIX: sửa lỗi chuyển từ tab MAIN SESSION sang SECTION-TAB và PIANO ROLL TAB thì bị bypass

This commit is contained in:
2026-08-07 09:13:03 +07:00
parent 23675dab83
commit 8cdf2942b9
4 changed files with 331 additions and 40 deletions
+161 -20
View File
@@ -118,12 +118,20 @@ const masteringChainOn = () => !!(window.currentMasteringSettings && window.curr
// (solo/preview/play) PHI đi qua mastering chain (user requirement: âm phi // (solo/preview/play) PHI đi qua mastering chain (user requirement: âm phi
// qua chain đ đ ln). Chain OFF theo maps như cũ. // qua chain đ đ ln). Chain OFF theo maps như cũ.
const effMidiBypass = (track) => { const effMidiBypass = (track) => {
// G override mastering-ON (17:00) nút quyết đnh bypass (user rules) // G override mastering-ON (17:00) nút quyết đnh bypass (user rules).
return trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass); // ƯU TIÊN track object (field midiBypass): track object phân bit đúng
// context (main track vs section clone id TRÙNG nhau); map keyed theo id
// gây collision (clone section ghi đè map ca main track cùng id MIDI
// section b route theo main = bypass sai). Map ch là fallback realtime.
if (track && track.midiBypass !== undefined) return !!track.midiBypass;
if (track && track.id && trackMidiBypassMap[track.id] !== undefined) return !!trackMidiBypassMap[track.id];
return false;
}; };
const effAudioBypass = (track) => { const effAudioBypass = (track) => {
// G override mastering-ON (17:00) nút A quyết đnh bypass (user rules) // G override mastering-ON (17:00) nút A quyết đnh bypass (user rules)
return trackAudioBypassMap[track.id] !== undefined ? !!trackAudioBypassMap[track.id] : !!(track.audioBypass ?? track.masteringBypass); if (track && track.audioBypass !== undefined) return !!track.audioBypass;
if (track && track.id && trackAudioBypassMap[track.id] !== undefined) return !!trackAudioBypassMap[track.id];
return false;
}; };
// Build the dual routing for one track: routeGain -> mastering chain (normal), // Build the dual routing for one track: routeGain -> mastering chain (normal),
@@ -133,10 +141,18 @@ function createMasteringRoute(ctx, track, bus) {
// falling back to the track object this guarantees the A-button toggle is // falling back to the track object this guarantees the A-button toggle is
// picked up even if a stale track object is passed in. // picked up even if a stale track object is passed in.
let bypass = false; let bypass = false;
if (track && track.id && trackAudioBypassMap[track.id] !== undefined) { // Bypass theo LOI ni dung track (rules nút A/): track MIDI-only nút
bypass = !!trackAudioBypassMap[track.id]; // quyết đnh KHÔNG dùng audioBypass (section track default audioBypass=true
// route dry midi section b bypass dù tt log: SF track node
// (sfEntry) + noteon chy nhưng âm dry).
const hasClips = track && ((track.clips && track.clips.length > 0) || !!track.buffer);
const hasMidi = track && track.midiItems && track.midiItems.length > 0;
if (hasMidi && !hasClips) {
// MIDI-only ; ƯU TIÊN field track object (context đúng id section
// clone TRÙNG main track; map keyed theo id b ghi đè chéo bypass sai).
bypass = track.midiBypass !== undefined ? !!track.midiBypass : (track.id && trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : false);
} else if (track) { } else if (track) {
bypass = !!(track.audioBypass ?? track.masteringBypass); bypass = track.audioBypass !== undefined ? !!track.audioBypass : (track.id && trackAudioBypassMap[track.id] !== undefined ? !!trackAudioBypassMap[track.id] : false);
} }
// Mastering chain ON MI track qua chain ( b override user requirement) // Mastering chain ON MI track qua chain ( b override user requirement)
// G 17:00 override nut nút A/ (log: audioBypass=true nhưng // G 17:00 override nut nút A/ (log: audioBypass=true nhưng
@@ -156,6 +172,23 @@ function createMasteringRoute(ctx, track, bus) {
return routeObj; return routeObj;
} }
// Bypass theo LOI ni dung track (dùng chung: createMasteringRoute, sync
// effect, startTrackPlayback re-apply) MIDI-only track nút ; có clips
// nút A. Section track default audioBypass=true nếu route đc audioBypass
// cho track MIDI midi b kéo vào dry (b bypass) dù tt.
function routeBypassFor(track) {
const hasC = track && ((track.clips && track.clips.length > 0) || !!track.buffer);
const hasM = track && track.midiItems && track.midiItems.length > 0;
if (hasM && !hasC) {
// MIDI-only ; ƯU TIÊN field track object (context đúng id section
// clone TRÙNG main track; map keyed theo id b ghi đè chéo bypass sai).
routeBypassFor._lastType = 'midi';
return track.midiBypass !== undefined ? !!track.midiBypass : (track.id && trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : false);
}
routeBypassFor._lastType = 'audio';
return track.audioBypass !== undefined ? !!track.audioBypass : (track.id && trackAudioBypassMap[track.id] !== undefined ? !!trackAudioBypassMap[track.id] : false);
}
// Live-toggle a route. HARD switch: cancel any pending automation and assign // Live-toggle a route. HARD switch: cancel any pending automation and assign
// .value directly (instant, cannot be delayed by the automation queue). // .value directly (instant, cannot be delayed by the automation queue).
function setMasteringRoute(route, bypass) { function setMasteringRoute(route, bypass) {
@@ -14731,9 +14764,13 @@ const App = () => {
// tracks state (loads, undo, AI ops). Placed AFTER the tracks/subTabs/ // tracks state (loads, undo, AI ops). Placed AFTER the tracks/subTabs/
// sessionTabs declarations (TDZ-safe). // sessionTabs declarations (TDZ-safe).
useEffect(() => { useEffect(() => {
const all = [...(tracks || []), ...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), [])]; // Sync theo th t: session/clone TRƯC, MAIN tracks SAU khi id trùng
// (section clone gi id sub-track = id track main), MAIN thng: map là
// fallback cho track không có field; clone có field (reset 0330) nên route
// dùng field object (context đúng) map chéo không nh hưng.
const all = [...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), []), ...(tracks || [])];
all.forEach(t => { all.forEach(t => {
trackMasteringBypassMap[t.id] = !!t.audioBypass; trackMasteringBypassMap[t.id] = !!t.masteringBypass;
trackAudioBypassMap[t.id] = !!t.audioBypass; trackAudioBypassMap[t.id] = !!t.audioBypass;
trackMidiBypassMap[t.id] = !!t.midiBypass; trackMidiBypassMap[t.id] = !!t.midiBypass;
}); });
@@ -14896,7 +14933,7 @@ const App = () => {
_n.sfDryGain.gain.value = _b ? 1 : 0; _n.sfDryGain.gain.value = _b ? 1 : 0;
} }
if (_n.route && _n.route.routeGain && _n.route.dryGain) { if (_n.route && _n.route.routeGain && _n.route.dryGain) {
const _ab = effAudioBypass(_t); const _ab = routeBypassFor(_t);
_n.route.routeGain.gain.value = _ab ? 0 : 1; _n.route.routeGain.gain.value = _ab ? 0 : 1;
_n.route.dryGain.gain.value = _ab ? 1 : 0; _n.route.dryGain.gain.value = _ab ? 1 : 0;
} }
@@ -16587,6 +16624,14 @@ const App = () => {
instrumentId: t.instrumentId || null, instrumentId: t.instrumentId || null,
instrumentProgram: t.instrumentProgram, instrumentProgram: t.instrumentProgram,
instrumentName: t.instrumentName || null, instrumentName: t.instrumentName || null,
// Reset bypass theo CONTEXT SECTION (KHÔNG kế tha t track gc):
// track gc có th audioByPass/masteringBypass=true (data lưu cũ)
// kế tha làm track MIDI section b kéo vào dry bus khi play (bypass
// mastering dù tt). Section default: audio raw (A sáng = bypass),
// MIDI qua mastering ( tt).
masteringBypass: false,
audioBypass: true,
midiBypass: false,
_isSectionClone: true _isSectionClone: true
}; };
}) : tracks.filter(t => t.id === trackId).map(t => ({ }) : tracks.filter(t => t.id === trackId).map(t => ({
@@ -16605,6 +16650,9 @@ const App = () => {
soundfont_program: undefined, soundfont_program: undefined,
instrument_source: null, instrument_source: null,
synth_engine: undefined, synth_engine: undefined,
masteringBypass: false,
audioBypass: true,
midiBypass: false,
_isSectionClone: true _isSectionClone: true
})); }));
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks, color: section.color || null }]); setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks, color: section.color || null }]);
@@ -18661,6 +18709,7 @@ const App = () => {
sfDryGain = context.createGain(); sfDryGain = context.createGain();
sfRouteGain.gain.value = sfBypass ? 0 : 1; sfRouteGain.gain.value = sfBypass ? 0 : 1;
sfDryGain.gain.value = sfBypass ? 1 : 0; sfDryGain.gain.value = sfBypass ? 1 : 0;
console.log('[Bypass] sfEntry created track', track.id, 'midiBypass=' + sfBypass, sfBypass ? 'DRY' : 'MASTERING', '| midiItems:', (track.midiItems || []).length);
sfPan.connect(sfRouteGain); sfPan.connect(sfRouteGain);
sfPan.connect(sfDryGain); sfPan.connect(sfDryGain);
sfRouteGain.connect(masterBus.input); sfRouteGain.connect(masterBus.input);
@@ -18758,7 +18807,16 @@ const App = () => {
const _activeTabId = activeTabRef.current; const _activeTabId = activeTabRef.current;
const _activeSub = subTabsRef.current ? subTabsRef.current.find(s => s.id === _activeTabId) : null; const _activeSub = subTabsRef.current ? subTabsRef.current.find(s => s.id === _activeTabId) : null;
if (_activeSub && _activeSub.type === 'PIANO_ROLL') { if (_activeSub && _activeSub.type === 'PIANO_ROLL') {
const _prNode = activeTrackNodesRef.current[_activeSub.trackId]; // PIANO ROLL ca track TRONG SECTION (parent_tab_id = session_*): id
// track trùng main track id lookup key = id trúng node MAIN (route
// theo main sai context MIDI b kéo dry). ƯU TIÊN SUB-NODE
// (key <parent>_sub_<id> route theo section + thng masterBus);
// không có sub-node null (masterBus.input qua mastering).
let _prNode = activeTrackNodesRef.current[_activeSub.trackId];
if (_activeSub.parent_tab_id && _activeSub.parent_tab_id.startsWith('session_')) {
const _prSubKeys = Object.keys(activeTrackNodesRef.current).filter(k => k.endsWith('_sub_' + _activeSub.trackId));
_prNode = _prSubKeys.length ? activeTrackNodesRef.current[_prSubKeys[0]] : null;
}
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) { if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
if (_prNode.sfEntry) { if (_prNode.sfEntry) {
window.SonicSF.setOutputDestination(_prNode.sfEntry); window.SonicSF.setOutputDestination(_prNode.sfEntry);
@@ -18779,23 +18837,40 @@ const App = () => {
} }
} }
const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0); const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0);
console.log('[SFRoute] activeTab=' + _activeTabId, 'midiAudible=' + midiAudible.map(t => t.id + '(midi' + (t.midiItems || []).length + ')').join(',') || 'none');
if (midiAudible.length === 1) { if (midiAudible.length === 1) {
const t = midiAudible[0]; const t = midiAudible[0];
const node = activeTrackNodesRef.current[t.id]; // SECTION-TAB: track id ca section clone TRÙNG main track id node
// chính (key = id) là node MAIN (route theo main SAI context, MIDI
// section b kéo vào dry). ƯU TIÊN SUB-NODE (key <parent>_sub_<id>
// route theo section + thng masterBus.input) không có sub-node
// null (SF v masterBus.input qua mastering, KHÔNG bypass).
let node = activeTrackNodesRef.current[t.id];
if (_activeTabId && _activeTabId.startsWith('session_')) {
const subKeys = Object.keys(activeTrackNodesRef.current).filter(k => k.endsWith('_sub_' + t.id));
node = subKeys.length ? activeTrackNodesRef.current[subKeys[0]] : null;
}
// SF ALWAYS enters the track's own FX chain (sfEntry sfModules when // SF ALWAYS enters the track's own FX chain (sfEntry sfModules when
// PWR ON). The button only switches the post-FX route (sfRouteGain / // PWR ON). The button only switches the post-FX route (sfRouteGain /
// sfDryGain) to skip or include the Mastering FX Chain. // sfDryGain) to skip or include the Mastering FX Chain.
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) { if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
console.log('[SFRoute] dest=sfEntry node', t.id, 'sfRouteGain=' + (node.sfRouteGain ? node.sfRouteGain.gain.value : 'MISSING'), 'sfDryGain=' + (node.sfDryGain ? node.sfDryGain.gain.value : 'MISSING'));
window.SonicSF.setOutputDestination(node.sfEntry); window.SonicSF.setOutputDestination(node.sfEntry);
// Ép route qua mastering chain NGAY ti thi đim routing. // Ép route qua mastering chain NGAY ti thi đim routing.
if (node.sfRouteGain && node.sfDryGain) { if (node.sfRouteGain && node.sfDryGain) {
const _b = effMidiBypass(t.id); // FIX: truyn track OBJECT (t) trưc đây truyn t.id (string)
// effMidiBypass đc track.id=undefined map lookup sai _b luôn
// false sfRouteGain b ép 1 (không bypass) nút vô hiu qua
// đưng này.
const _b = effMidiBypass(t);
node.sfRouteGain.gain.value = _b ? 0 : 1; node.sfRouteGain.gain.value = _b ? 0 : 1;
node.sfDryGain.gain.value = _b ? 1 : 0; node.sfDryGain.gain.value = _b ? 1 : 0;
console.log('[Bypass] sf route sync track', t.id, 'midiBypass=' + _b, _b ? 'DRY' : 'MASTERING');
} }
return; return;
} }
if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) { if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) {
console.log('[SFRoute] dest=gainNode node', t.id, '(no sfEntry) — route.routeGain=' + (node.route ? node.route.routeGain.gain.value : 'MISSING'), 'route.dryGain=' + (node.route ? node.route.dryGain.gain.value : 'MISSING'));
window.SonicSF.setOutputDestination(node.gainNode); window.SonicSF.setOutputDestination(node.gainNode);
return; return;
} }
@@ -18909,21 +18984,35 @@ const App = () => {
// RULES: sub-track có CLIPS nút A (audioBypass); có MIDI nút (midiBypass). // RULES: sub-track có CLIPS nút A (audioBypass); có MIDI nút (midiBypass).
const subHasClips = (subTrack.clips && subTrack.clips.length > 0) || !!subTrack.buffer; const subHasClips = (subTrack.clips && subTrack.clips.length > 0) || !!subTrack.buffer;
const subBypass = subHasClips const subBypass = subHasClips
? (trackAudioBypassMap[subTrack.id] !== undefined ? !!trackAudioBypassMap[subTrack.id] : !!(subTrack.audioBypass ?? subTrack.masteringBypass)) ? (subTrack.audioBypass !== undefined ? !!subTrack.audioBypass : (trackAudioBypassMap[subTrack.id] !== undefined ? !!trackAudioBypassMap[subTrack.id] : false))
: (trackMidiBypassMap[subTrack.id] !== undefined ? !!trackMidiBypassMap[subTrack.id] : !!(subTrack.midiBypass ?? subTrack.masteringBypass)); : (subTrack.midiBypass !== undefined ? !!subTrack.midiBypass : (trackMidiBypassMap[subTrack.id] !== undefined ? !!trackMidiBypassMap[subTrack.id] : false));
const routeGain = context.createGain(); const routeGain = context.createGain();
const dryGain = context.createGain(); const dryGain = context.createGain();
routeGain.gain.value = subBypass ? 0 : 1; routeGain.gain.value = subBypass ? 0 : 1;
dryGain.gain.value = subBypass ? 1 : 0; dryGain.gain.value = subBypass ? 1 : 0;
gainNode.connect(routeGain); // analyserNode cho VU meter ca SECTION-TAB track strip (VU tick đc
gainNode.connect(dryGain); // node.analyserNode sub-node trưc đây không có VU không animation).
routeGain.connect(parentNode); const subAnalyser = context.createAnalyser();
subAnalyser.fftSize = 256;
subAnalyser.smoothingTimeConstant = 0.4;
gainNode.connect(subAnalyser);
subAnalyser.connect(routeGain);
subAnalyser.connect(dryGain);
// Mastering path masterBus.input TRC TIP (không qua parent node:
// parent có route riêng vi bypass riêng parent audio_bypass=true
// ni qua parent làm MIDI/audio section b kéo vào dry bus = bypass
// mastering dù sub-track /A đang tt).
if (window.masterBus && window.masterBus.input) {
routeGain.connect(window.masterBus.input);
} else {
routeGain.connect(parentNode);
}
if (window.masterBus && window.masterBus.dryInput) { if (window.masterBus && window.masterBus.dryInput) {
dryGain.connect(window.masterBus.dryInput); dryGain.connect(window.masterBus.dryInput);
} else { } else {
dryGain.connect(parentNode); dryGain.connect(parentNode);
} }
node = { gainNode, pannerNode: null, route: { routeGain: routeGain, dryGain: dryGain, _trackId: subTrack.id } }; node = { gainNode, pannerNode: null, analyserNode: subAnalyser, route: { routeGain: routeGain, dryGain: dryGain, _trackId: subTrack.id } };
activeTrackNodesRef.current[subKey] = node; activeTrackNodesRef.current[subKey] = node;
} }
return node.gainNode; return node.gainNode;
@@ -18964,6 +19053,17 @@ const App = () => {
const gainNode = getOrCreateTrackNode(track, context); const gainNode = getOrCreateTrackNode(track, context);
const pannerNode = activeTrackNodesRef.current[track.id].pannerNode; const pannerNode = activeTrackNodesRef.current[track.id].pannerNode;
// Re-apply route gains theo content-type NGAY ti thi đim play node
// có th STALE (to t MAIN play vi track cùng id route theo data cũ)
// first play sau double-click vào SECTION-TAB b bypass. Re-apply
// routeBypassFor(track) mi ln schedule s hết chu k "stop+play mi đúng".
const playNode = activeTrackNodesRef.current[track.id];
if (playNode && playNode.route && playNode.route.routeGain) {
const rb = routeBypassFor(track);
playNode.route.routeGain.gain.value = rb ? 0 : 1;
playNode.route.dryGain.gain.value = rb ? 1 : 0;
console.log('[Bypass] reapply track', track.id, 'contentType=' + (routeBypassFor._lastType ? 'midi' : 'audio'), 'bypass=' + rb, '→ routeGain=' + playNode.route.routeGain.gain.value + '/dryGain=' + playNode.route.dryGain.gain.value);
}
console.log('[Play] track', track.id, track.name, 'node ok:', !!(gainNode && pannerNode), 'muted:', track.muted); console.log('[Play] track', track.id, track.name, 'node ok:', !!(gainNode && pannerNode), 'muted:', track.muted);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default', id: 'default',
@@ -19084,6 +19184,15 @@ const App = () => {
const subNode = getOrCreateSubTrackNode(track, subTrack, context); const subNode = getOrCreateSubTrackNode(track, subTrack, context);
if (!subNode) return; if (!subNode) return;
// Re-apply route sub-node theo content-type mi ln schedule
// sub-node có th STALE (route theo trng thái cũ) midi section
// b kéo vào dry. routeBypassFor: MIDI-only , clips A.
const subNodeObj = activeTrackNodesRef.current[track.id + '_sub_' + subTrack.id];
if (subNodeObj && subNodeObj.route && subNodeObj.route.routeGain) {
const srb = routeBypassFor(subTrack);
subNodeObj.route.routeGain.gain.value = srb ? 0 : 1;
subNodeObj.route.dryGain.gain.value = srb ? 1 : 0;
}
// 1. Play clips in subTrack // 1. Play clips in subTrack
const subClips = subTrack.clips || []; const subClips = subTrack.clips || [];
@@ -19166,6 +19275,14 @@ const App = () => {
subTrack.synth_engine subTrack.synth_engine
); );
} }
// Trigger VU meter flash đúng nhp (ging main-track midi 19066)
// C subTrack (VU section tab) + parent track (VU MAIN section
// item đang play) midiVuActivity keyed theo track id trigger
// c 2 đ VU main nhy theo section.
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(subTrack.id, note.velocity || 0.8);
window.triggerMidiVuActivity(track.id, note.velocity || 0.8);
}
} }
}); });
}); });
@@ -24603,6 +24720,12 @@ STRICT CONSTRAINTS:
const node = trackNodes[trackId]; const node = trackNodes[trackId];
const canvas = trackVuRefs.current[key]; const canvas = trackVuRefs.current[key];
if (!canvas) return; if (!canvas) return;
// Section tab: track nodes nm dưi key <parent>_sub_<trackId>
// lookup plain key tr undefined VU không animation. Fallback scan _sub_.
const vNode = node || (function() {
for (var k in trackNodes) { if (k.endsWith('_sub_' + trackId)) return trackNodes[k]; }
return null;
})();
// Only animate when real audio actually passes through the track (solo/mute aware) // Only animate when real audio actually passes through the track (solo/mute aware)
var vuTracks = activeTracksRef.current || []; var vuTracks = activeTracksRef.current || [];
@@ -24611,8 +24734,8 @@ STRICT CONSTRAINTS:
var isAudible = vuTrack ? (anySoloVU ? !!vuTrack.solo : !vuTrack.muted) : true; var isAudible = vuTrack ? (anySoloVU ? !!vuTrack.solo : !vuTrack.muted) : true;
let audioPeak = 0; let audioPeak = 0;
if (isAudible && node && node.analyserNode) { if (isAudible && vNode && vNode.analyserNode) {
const analyser = node.analyserNode; const analyser = vNode.analyserNode;
const data = new Uint8Array(128); const data = new Uint8Array(128);
analyser.getByteTimeDomainData(data); analyser.getByteTimeDomainData(data);
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
@@ -24620,6 +24743,24 @@ STRICT CONSTRAINTS:
if (v > audioPeak) audioPeak = v; if (v > audioPeak) audioPeak = v;
} }
} }
// Section item trên MAIN track: VU theo sub-nodes ca track này (key
// <trackId>_sub_*) âm section đi thng masterBus.input (không qua
// node chính) node chính không có tín hiu VU track đng yên.
if (audioPeak <= 0.001) {
for (var sk in trackNodes) {
if (sk.indexOf(trackId + '_sub_') === 0) {
const sn = trackNodes[sk];
if (sn && sn.analyserNode) {
const d2 = new Uint8Array(128);
sn.analyserNode.getByteTimeDomainData(d2);
for (let i = 0; i < d2.length; i++) {
const v = Math.abs(d2[i] - 128) / 128;
if (v > audioPeak) audioPeak = v;
}
}
}
}
}
// midiVuActivityRef đưc set bi triggerMidiVuActivity c khi play // midiVuActivityRef đưc set bi triggerMidiVuActivity c khi play
// MIDI item LN khi ARM + nhn phím MIDI keyboard (preview) nên VU // MIDI item LN khi ARM + nhn phím MIDI keyboard (preview) nên VU
+80 -19
View File
@@ -25,18 +25,34 @@ const trackMasteringBypassMap={};const trackAudioBypassMap={};const trackMidiByp
const masteringChainOn=()=>!!(window.currentMasteringSettings&&window.currentMasteringSettings.masterConnected&&!window.currentMasteringSettings.isBypassed);// ♪ bypass hiệu lực CHỈ khi mastering chain TẮT — khi chain ON, MỌI track const masteringChainOn=()=>!!(window.currentMasteringSettings&&window.currentMasteringSettings.masterConnected&&!window.currentMasteringSettings.isBypassed);// ♪ bypass hiệu lực CHỈ khi mastering chain TẮT — khi chain ON, MỌI track
// (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải // (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải
// qua chain để đủ lớn). Chain OFF → theo ♪ maps như cũ. // qua chain để đủ lớn). Chain OFF → theo ♪ maps như cũ.
const effMidiBypass=track=>{// GỠ override mastering-ON (17:00) — nút ♪ quyết định bypass (user rules) const effMidiBypass=track=>{// GỠ override mastering-ON (17:00) — nút ♪ quyết định bypass (user rules).
return trackMidiBypassMap[track.id]!==undefined?!!trackMidiBypassMap[track.id]:!!(track.midiBypass??track.masteringBypass);};const effAudioBypass=track=>{// GỠ override mastering-ON (17:00) — nút A quyết định bypass (user rules) // ƯU TIÊN track object (field midiBypass): track object phân biệt đúng
return trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:!!(track.audioBypass??track.masteringBypass);};// Build the dual routing for one track: routeGain -> mastering chain (normal), // context (main track vs section clone — id TRÙNG nhau); map keyed theo id
// gây collision (clone section ghi đè map của main track cùng id → MIDI
// section bị route theo ♪ main = bypass sai). Map chỉ là fallback realtime.
if(track&&track.midiBypass!==undefined)return!!track.midiBypass;if(track&&track.id&&trackMidiBypassMap[track.id]!==undefined)return!!trackMidiBypassMap[track.id];return false;};const effAudioBypass=track=>{// GỠ override mastering-ON (17:00) — nút A quyết định bypass (user rules)
if(track&&track.audioBypass!==undefined)return!!track.audioBypass;if(track&&track.id&&trackAudioBypassMap[track.id]!==undefined)return!!trackAudioBypassMap[track.id];return false;};// Build the dual routing for one track: routeGain -> mastering chain (normal),
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values. // dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
function createMasteringRoute(ctx,track,bus){// Prefer the live bypass map (synced from the tracks state on every render), function createMasteringRoute(ctx,track,bus){// Prefer the live bypass map (synced from the tracks state on every render),
// falling back to the track object — this guarantees the A-button toggle is // falling back to the track object — this guarantees the A-button toggle is
// picked up even if a stale track object is passed in. // picked up even if a stale track object is passed in.
let bypass=false;if(track&&track.id&&trackAudioBypassMap[track.id]!==undefined){bypass=!!trackAudioBypassMap[track.id];}else if(track){bypass=!!(track.audioBypass??track.masteringBypass);}// Mastering chain ON → MỌI track qua chain (♪ bị override — user requirement) let bypass=false;// Bypass theo LOẠI nội dung track (rules nút A/♪): track MIDI-only → nút ♪
// quyết định — KHÔNG dùng audioBypass (section track default audioBypass=true
// → route dry → midi section bị bypass dù ♪ tắt — log: SF → track node
// (sfEntry) + noteon chạy nhưng âm dry).
const hasClips=track&&(track.clips&&track.clips.length>0||!!track.buffer);const hasMidi=track&&track.midiItems&&track.midiItems.length>0;if(hasMidi&&!hasClips){// MIDI-only → ♪; ƯU TIÊN field track object (context đúng — id section
// clone TRÙNG main track; map keyed theo id bị ghi đè chéo → bypass sai).
bypass=track.midiBypass!==undefined?!!track.midiBypass:track.id&&trackMidiBypassMap[track.id]!==undefined?!!trackMidiBypassMap[track.id]:false;}else if(track){bypass=track.audioBypass!==undefined?!!track.audioBypass:track.id&&trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:false;}// Mastering chain ON → MỌI track qua chain (♪ bị override — user requirement)
// ⚠️ GỠ 17:00 — override nuốt nút A/♪ (log: audioBypass=true nhưng // ⚠️ GỠ 17:00 — override nuốt nút A/♪ (log: audioBypass=true nhưng
// routeGain=1) — rules hiện tại: NÚT A/♪ quyết định bypass, không phụ thuộc // routeGain=1) — rules hiện tại: NÚT A/♪ quyết định bypass, không phụ thuộc
// mastering ON. // mastering ON.
const routeGain=ctx.createGain();const dryGain=ctx.createGain();const masterDest=bus?bus.input:ctx.destination;const dryDest=bus&&bus.dryInput?bus.dryInput:ctx.destination;routeGain.gain.value=bypass?0:1;dryGain.gain.value=bypass?1:0;routeGain.connect(masterDest);dryGain.connect(dryDest);const routeObj={routeGain,dryGain};routeObj._trackId=track&&track.id;routeObj._bypass=bypass;return routeObj;}// Live-toggle a route. HARD switch: cancel any pending automation and assign const routeGain=ctx.createGain();const dryGain=ctx.createGain();const masterDest=bus?bus.input:ctx.destination;const dryDest=bus&&bus.dryInput?bus.dryInput:ctx.destination;routeGain.gain.value=bypass?0:1;dryGain.gain.value=bypass?1:0;routeGain.connect(masterDest);dryGain.connect(dryDest);const routeObj={routeGain,dryGain};routeObj._trackId=track&&track.id;routeObj._bypass=bypass;return routeObj;}// Bypass theo LOẠI nội dung track (dùng chung: createMasteringRoute, sync
// effect, startTrackPlayback re-apply) — MIDI-only track → nút ♪; có clips →
// nút A. Section track default audioBypass=true — nếu route đọc audioBypass
// cho track MIDI → midi bị kéo vào dry (bị bypass) dù ♪ tắt.
function routeBypassFor(track){const hasC=track&&(track.clips&&track.clips.length>0||!!track.buffer);const hasM=track&&track.midiItems&&track.midiItems.length>0;if(hasM&&!hasC){// MIDI-only → ♪; ƯU TIÊN field track object (context đúng — id section
// clone TRÙNG main track; map keyed theo id bị ghi đè chéo → bypass sai).
routeBypassFor._lastType='midi';return track.midiBypass!==undefined?!!track.midiBypass:track.id&&trackMidiBypassMap[track.id]!==undefined?!!trackMidiBypassMap[track.id]:false;}routeBypassFor._lastType='audio';return track.audioBypass!==undefined?!!track.audioBypass:track.id&&trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:false;}// Live-toggle a route. HARD switch: cancel any pending automation and assign
// .value directly (instant, cannot be delayed by the automation queue). // .value directly (instant, cannot be delayed by the automation queue).
function setMasteringRoute(route,bypass){if(!route)return;const on=!!bypass;try{const ctx=typeof getAudioContext==='function'?getAudioContext():null;if(!ctx)return;const t=ctx.currentTime;route.routeGain.gain.cancelScheduledValues(t);route.dryGain.gain.cancelScheduledValues(t);route.routeGain.gain.value=on?0:1;route.dryGain.gain.value=on?1:0;route._bypass=on;console.log('[Bypass] track',route._trackId,'audioBypass='+on,'→',on?'DRY BUS (bỏ mastering + bỏ track FX)':'MASTERING CHAIN (qua FX + mastering)');}catch(e){console.warn('setMasteringRoute error:',e);}}// Realtime mute/solo: audible linear gain for a track given the full track list function setMasteringRoute(route,bypass){if(!route)return;const on=!!bypass;try{const ctx=typeof getAudioContext==='function'?getAudioContext():null;if(!ctx)return;const t=ctx.currentTime;route.routeGain.gain.cancelScheduledValues(t);route.dryGain.gain.cancelScheduledValues(t);route.routeGain.gain.value=on?0:1;route.dryGain.gain.value=on?1:0;route._bypass=on;console.log('[Bypass] track',route._trackId,'audioBypass='+on,'→',on?'DRY BUS (bỏ mastering + bỏ track FX)':'MASTERING CHAIN (qua FX + mastering)');}catch(e){console.warn('setMasteringRoute error:',e);}}// Realtime mute/solo: audible linear gain for a track given the full track list
// of the CURRENT context. Solo semantics: if ANY track is soloed, only soloed // of the CURRENT context. Solo semantics: if ANY track is soloed, only soloed
@@ -663,7 +679,11 @@ const trackAudibleRef=useRef({});// Audio-relevant signature (mute/solo/vol/bypa
const trackAudioSyncSigRef=useRef(null);// Keep the mastering-bypass routing map + realtime mute/solo in sync with the const trackAudioSyncSigRef=useRef(null);// Keep the mastering-bypass routing map + realtime mute/solo in sync with the
// tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/ // tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/
// sessionTabs declarations (TDZ-safe). // sessionTabs declarations (TDZ-safe).
useEffect(()=>{const all=[...(tracks||[]),...(sessionTabs||[]).reduce((acc,s)=>acc.concat(s.tracks||[]),[])];all.forEach(t=>{trackMasteringBypassMap[t.id]=!!t.audioBypass;trackAudioBypassMap[t.id]=!!t.audioBypass;trackMidiBypassMap[t.id]=!!t.midiBypass;});const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:all;// Audio-relevant signature: đổi MÀU/rename track (không liên quan audio) useEffect(()=>{// Sync theo thứ tự: session/clone TRƯỚC, MAIN tracks SAU — khi id trùng
// (section clone giữ id sub-track = id track main), MAIN thắng: map là
// fallback cho track không có field; clone có field (reset 0330) nên route
// dùng field object (context đúng) — map chéo không ảnh hưởng.
const all=[...(sessionTabs||[]).reduce((acc,s)=>acc.concat(s.tracks||[]),[]),...(tracks||[])];all.forEach(t=>{trackMasteringBypassMap[t.id]=!!t.masteringBypass;trackAudioBypassMap[t.id]=!!t.audioBypass;trackMidiBypassMap[t.id]=!!t.midiBypass;});const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:all;// Audio-relevant signature: đổi MÀU/rename track (không liên quan audio)
// KHÔNG được trigger re-sync/re-route (tránh mất âm khi play sau đó — // KHÔNG được trigger re-sync/re-route (tránh mất âm khi play sau đó —
// updateSfRouting chạy thừa có thể đặt SF destination sai thời điểm). // updateSfRouting chạy thừa có thể đặt SF destination sai thời điểm).
const audioSig=list.map(t=>(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0)+':'+(t.audioBypass?'1':'0')+(t.midiBypass?'1':'0')+':'+(t.midiItems||[]).length+':'+(t.clips||[]).length+':'+(t.sections||[]).length).join('|');if(trackAudioSyncSigRef.current===audioSig)return;trackAudioSyncSigRef.current=audioSig;list.forEach(t=>{// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes const audioSig=list.map(t=>(t.muted?'1':'0')+(t.solo?'1':'0')+':'+(t.volumeDb??0)+':'+(t.audioBypass?'1':'0')+(t.midiBypass?'1':'0')+':'+(t.midiItems||[]).length+':'+(t.clips||[]).length+':'+(t.sections||[]).length).join('|');if(trackAudioSyncSigRef.current===audioSig)return;trackAudioSyncSigRef.current=audioSig;list.forEach(t=>{// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes
@@ -683,7 +703,7 @@ const[isMandatoryLogin,setIsMandatoryLogin]=useState(false);const[profileModalOp
const[fxRackTarget,setFxRackTarget]=useState(null);window.__openFxRack=(trackId,trackName)=>setFxRackTarget({trackId,trackName});const[masteringSettings,setMasteringSettings]=useState({masterConnected:false,activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0});useEffect(()=>{window.currentMasteringSettings=masteringSettings;if(audioCtx&&masterBus){toggleMasteringOnMaster(masteringSettings.masterConnected,masteringSettings.isBypassed);applyMasteringSettings(masteringSettings);// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị const[fxRackTarget,setFxRackTarget]=useState(null);window.__openFxRack=(trackId,trackName)=>setFxRackTarget({trackId,trackName});const[masteringSettings,setMasteringSettings]=useState({masterConnected:false,activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0});useEffect(()=>{window.currentMasteringSettings=masteringSettings;if(audioCtx&&masterBus){toggleMasteringOnMaster(masteringSettings.masterConnected,masteringSettings.isBypassed);applyMasteringSettings(masteringSettings);// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp // override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
// ngay lên node đang phát (không chờ tracks effect). // ngay lên node đang phát (không chờ tracks effect).
try{const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;_list.forEach(_t=>{const _n=activeTrackNodesRef.current[_t.id];if(_n){if(_n.sfRouteGain&&_n.sfDryGain){const _b=effMidiBypass(_t);_n.sfRouteGain.gain.value=_b?0:1;_n.sfDryGain.gain.value=_b?1:0;}if(_n.route&&_n.route.routeGain&&_n.route.dryGain){const _ab=effAudioBypass(_t);_n.route.routeGain.gain.value=_ab?0:1;_n.route.dryGain.gain.value=_ab?1:0;}}});updateSfRouting();}catch(e){}}},[masteringSettings]);const[pluginManagerModalOpen,setPluginManagerModalOpen]=useState(false);const[pluginsData,setPluginsData]=useState(null);const loadAudioBuffersForTracks=async tracksList=>{let hasLoadedAny=false;const loadBuffer=async url=>{const res=await fetch(url);if(!res.ok)return null;const blob=await res.blob();return await window.SonicAudio.decodeAudioFile(blob);};const tryLoad=async fileId=>{if(!fileId)return null;try{const result=await loadBuffer('/static/audio/uploads/'+fileId);if(result)return result;}catch(_){}try{const result=await loadBuffer(`${API_AUDIO}/download/${fileId}`);if(result)return result;}catch(_){}return null;};const updatedTracks=await Promise.all(tracksList.map(async t=>{let trackBuffer=t.buffer;let trackChannelInfo=t.channelInfo;if(t.serverFileId&&!trackBuffer){const result=await tryLoad(t.serverFileId);if(result){trackBuffer=result.audioBuffer;trackChannelInfo=result.channelInfo;hasLoadedAny=true;}}const updatedClips=await Promise.all((t.clips||[]).map(async c=>{let clipBuffer=c.buffer;const targetFileId=c.serverFileId||t.serverFileId;if(targetFileId&&!clipBuffer){const result=await tryLoad(targetFileId);if(result){clipBuffer=result.audioBuffer;hasLoadedAny=true;}}return{...c,buffer:clipBuffer};}));// Section items: nạp CẢ audioclip bên trong section (trước đây bỏ sót → try{const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;_list.forEach(_t=>{const _n=activeTrackNodesRef.current[_t.id];if(_n){if(_n.sfRouteGain&&_n.sfDryGain){const _b=effMidiBypass(_t);_n.sfRouteGain.gain.value=_b?0:1;_n.sfDryGain.gain.value=_b?1:0;}if(_n.route&&_n.route.routeGain&&_n.route.dryGain){const _ab=routeBypassFor(_t);_n.route.routeGain.gain.value=_ab?0:1;_n.route.dryGain.gain.value=_ab?1:0;}}});updateSfRouting();}catch(e){}}},[masteringSettings]);const[pluginManagerModalOpen,setPluginManagerModalOpen]=useState(false);const[pluginsData,setPluginsData]=useState(null);const loadAudioBuffersForTracks=async tracksList=>{let hasLoadedAny=false;const loadBuffer=async url=>{const res=await fetch(url);if(!res.ok)return null;const blob=await res.blob();return await window.SonicAudio.decodeAudioFile(blob);};const tryLoad=async fileId=>{if(!fileId)return null;try{const result=await loadBuffer('/static/audio/uploads/'+fileId);if(result)return result;}catch(_){}try{const result=await loadBuffer(`${API_AUDIO}/download/${fileId}`);if(result)return result;}catch(_){}return null;};const updatedTracks=await Promise.all(tracksList.map(async t=>{let trackBuffer=t.buffer;let trackChannelInfo=t.channelInfo;if(t.serverFileId&&!trackBuffer){const result=await tryLoad(t.serverFileId);if(result){trackBuffer=result.audioBuffer;trackChannelInfo=result.channelInfo;hasLoadedAny=true;}}const updatedClips=await Promise.all((t.clips||[]).map(async c=>{let clipBuffer=c.buffer;const targetFileId=c.serverFileId||t.serverFileId;if(targetFileId&&!clipBuffer){const result=await tryLoad(targetFileId);if(result){clipBuffer=result.audioBuffer;hasLoadedAny=true;}}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). // 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}],sections:updatedSections};}return{...t,buffer:trackBuffer,channelInfo:trackChannelInfo,clips:updatedClips,sections:updatedSections};}));if(hasLoadedAny){setTracks(prev=>{// Merge by TRACK ID (not array index): if the state changed between the 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}],sections:updatedSections};}return{...t,buffer:trackBuffer,channelInfo:trackChannelInfo,clips:updatedClips,sections:updatedSections};}));if(hasLoadedAny){setTracks(prev=>{// Merge by TRACK ID (not array index): if the state changed between the
// fetch start and now (e.g. another project opened), index-based merging // fetch start and now (e.g. another project opened), index-based merging
@@ -751,7 +771,12 @@ setSessionTabs(prev=>prev.map(s=>s.id===tabId?{...s,isDirty:false}:s));showToast
const handleEditSectionInTab=(trackId,sectionId)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const section=(track.sections||[]).find(s=>s.id===sectionId);if(!section)return;const existing=sessionTabs.find(s=>s.sectionId===sectionId);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?section.tracks.map(t=>{// Giữ CLIPS + gắn lại buffer THẬT (JSON.parse(JSON.stringify()) DROP const handleEditSectionInTab=(trackId,sectionId)=>{const track=tracks.find(t=>t.id===trackId);if(!track)return;const section=(track.sections||[]).find(s=>s.id===sectionId);if(!section)return;const existing=sessionTabs.find(s=>s.sectionId===sectionId);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?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:[] // 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. // → 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:[],midiItems:[],markers:[],isArmed:false,monitoringEnabled:true,instrumentId:null,instrumentProgram:undefined,instrumentName:null,soundfont_id:null,soundfont_bank:undefined,soundfont_program:undefined,instrument_source:null,synth_engine:undefined,_isSectionClone:true}));setSessionTabs(prev=>[...prev,{id:tabId,name:tabName,sectionId:sectionId,tracks:clonedTracks,color:section.color||null}]);setActiveTab(tabId);};// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── 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,// Reset bypass theo CONTEXT SECTION (KHÔNG kế thừa từ track gốc):
// track gốc có thể audioByPass/masteringBypass=true (data lưu cũ) →
// kế thừa làm track MIDI section bị kéo vào dry bus khi play (bypass
// mastering dù ♪ tắt). Section default: audio raw (A sáng = bypass),
// MIDI qua mastering (♪ tắt).
masteringBypass:false,audioBypass:true,midiBypass:false,_isSectionClone:true};}):tracks.filter(t=>t.id===trackId).map(t=>({...t,clips:[],sections:[],midiItems:[],markers:[],isArmed:false,monitoringEnabled:true,instrumentId:null,instrumentProgram:undefined,instrumentName:null,soundfont_id:null,soundfont_bank:undefined,soundfont_program:undefined,instrument_source:null,synth_engine:undefined,masteringBypass:false,audioBypass:true,midiBypass:false,_isSectionClone:true}));setSessionTabs(prev=>[...prev,{id:tabId,name:tabName,sectionId:sectionId,tracks:clonedTracks,color:section.color||null}]);setActiveTab(tabId);};// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
const applySubTab=tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const track=activeTracks.find(t=>t.id===subTab.trackId);if(!track||!track.buffer)return;const beforeSnap=captureTrackSnapshot(subTab.trackId);// Clone buffer and apply effects const applySubTab=tabId=>{const subTab=subTabs.find(s=>s.id===tabId);if(!subTab||!subTab.buffer)return;const track=activeTracks.find(t=>t.id===subTab.trackId);if(!track||!track.buffer)return;const beforeSnap=captureTrackSnapshot(subTab.trackId);// Clone buffer and apply effects
const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Apply effects inline const ctx=getAudioContext();const sr=subTab.buffer.sampleRate;const eff=subTab.buffer.getChannelData(0);let resultBuffer=ctx.createBuffer(1,eff.length,sr);let resultData=resultBuffer.getChannelData(0);resultData.set(eff);// Apply effects inline
const fx=subTab.effects||{};const applyResample=(data,ratio)=>{const newLen=Math.round(data.length*ratio);const out=new Float32Array(newLen);for(let i=0;i<newLen;i++){const srcIdx=i/ratio;const idx0=Math.floor(srcIdx);const idx1=Math.min(idx0+1,data.length-1);const frac=srcIdx-idx0;out[i]=data[idx0]*(1-frac)+data[idx1]*frac;}return out;};if(fx.reverse){const rev=new Float32Array(resultData);for(let i=0;i<resultData.length;i++)rev[i]=resultData[resultData.length-1-i];resultData.set(rev);}if(fx.gainDb!==0){const gain=Math.pow(10,fx.gainDb/20);for(let i=0;i<resultData.length;i++)resultData[i]=Math.max(-1,Math.min(1,resultData[i]*gain));}if(fx.fadeInMs>0){const fadeSamples=Math.min(resultData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i<fadeSamples;i++)resultData[i]*=i/fadeSamples;}// Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3) const fx=subTab.effects||{};const applyResample=(data,ratio)=>{const newLen=Math.round(data.length*ratio);const out=new Float32Array(newLen);for(let i=0;i<newLen;i++){const srcIdx=i/ratio;const idx0=Math.floor(srcIdx);const idx1=Math.min(idx0+1,data.length-1);const frac=srcIdx-idx0;out[i]=data[idx0]*(1-frac)+data[idx1]*frac;}return out;};if(fx.reverse){const rev=new Float32Array(resultData);for(let i=0;i<resultData.length;i++)rev[i]=resultData[resultData.length-1-i];resultData.set(rev);}if(fx.gainDb!==0){const gain=Math.pow(10,fx.gainDb/20);for(let i=0;i<resultData.length;i++)resultData[i]=Math.max(-1,Math.min(1,resultData[i]*gain));}if(fx.fadeInMs>0){const fadeSamples=Math.min(resultData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;i<fadeSamples;i++)resultData[i]*=i/fadeSamples;}// Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3)
@@ -914,7 +939,7 @@ sfEntry.gain.value=1;sfOut.connect(sfPan);// Feed the soundfont into the shared
sfOut.connect(scopeSplitter);// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX sfOut.connect(scopeSplitter);// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX
// Rack modules are still applied — PWR controls those). sfRouteGain → // Rack modules are still applied — PWR controls those). sfRouteGain →
// masterBus.input (mastering), sfDryGain → dry bus (skip mastering). // masterBus.input (mastering), sfDryGain → dry bus (skip mastering).
const sfBypass=effMidiBypass(track);sfRouteGain=context.createGain();sfDryGain=context.createGain();sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);sfOut.connect(sfAnalyser);const sfModsBuilt=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,context,m.params);}catch(e){return null;}}).filter(Boolean):[];sfMods=sfModsBuilt;let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);}node={gainNode,pannerNode,fxStopFn,analyserNode,route,fxEntry,fxLegacyIn,scopeAnalyserL,scopeAnalyserR,sfEntry,sfOut,sfPan,sfAnalyser,sfRouteGain,sfDryGain,fxMods:chainMods,sfMods};// Realtime mute/solo: apply the track's current mute/solo/volume state to const sfBypass=effMidiBypass(track);sfRouteGain=context.createGain();sfDryGain=context.createGain();sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;console.log('[Bypass] sfEntry created track',track.id,'midiBypass='+sfBypass,sfBypass?'DRY':'MASTERING','| midiItems:',(track.midiItems||[]).length);sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);sfOut.connect(sfAnalyser);const sfModsBuilt=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,context,m.params);}catch(e){return null;}}).filter(Boolean):[];sfMods=sfModsBuilt;let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);}node={gainNode,pannerNode,fxStopFn,analyserNode,route,fxEntry,fxLegacyIn,scopeAnalyserL,scopeAnalyserR,sfEntry,sfOut,sfPan,sfAnalyser,sfRouteGain,sfDryGain,fxMods:chainMods,sfMods};// Realtime mute/solo: apply the track's current mute/solo/volume state to
// the fresh node so items of muted/soloed tracks start correctly. // the fresh node so items of muted/soloed tracks start correctly.
const trackList=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:[track];const nodeAudibleGain=computeTrackAudibleGain(trackList,track);setTrackNodeGain(node,nodeAudibleGain);if(node.sfEntry){try{const g=typeof nodeAudibleGain==='number'&&isFinite(nodeAudibleGain)&&!isNaN(nodeAudibleGain)?nodeAudibleGain:1.0;node.sfEntry.gain.setTargetAtTime(g,context.currentTime,0.02);}catch(e){}}activeTrackNodesRef.current[track.id]=node;console.log('[Bypass] node created track',track.id,'initial audioBypass=',!!trackAudioBypassMap[track.id],'routeGain=',node.route.routeGain.gain.value,'dryGain=',node.route.dryGain.gain.value);updateSfRouting();}return node.gainNode;};// Per-context audio graph reconstruction (unified_fx_rack_panel.md §III.2): const trackList=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:[track];const nodeAudibleGain=computeTrackAudibleGain(trackList,track);setTrackNodeGain(node,nodeAudibleGain);if(node.sfEntry){try{const g=typeof nodeAudibleGain==='number'&&isFinite(nodeAudibleGain)&&!isNaN(nodeAudibleGain)?nodeAudibleGain:1.0;node.sfEntry.gain.setTargetAtTime(g,context.currentTime,0.02);}catch(e){}}activeTrackNodesRef.current[track.id]=node;console.log('[Bypass] node created track',track.id,'initial audioBypass=',!!trackAudioBypassMap[track.id],'routeGain=',node.route.routeGain.gain.value,'dryGain=',node.route.dryGain.gain.value);updateSfRouting();}return node.gainNode;};// Per-context audio graph reconstruction (unified_fx_rack_panel.md §III.2):
// re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn), // re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn),
@@ -930,13 +955,27 @@ const updateSfRouting=()=>{try{const list=activeTracksRef.current&&activeTracksR
// TRACK đang edit (sfEntry → FX Rack riêng, fallback gainNode) để fader/ // TRACK đang edit (sfEntry → FX Rack riêng, fallback gainNode) để fader/
// pan/FX áp đúng cho notes đang nghe — kể cả khi project có >1 track MIDI // pan/FX áp đúng cho notes đang nghe — kể cả khi project có >1 track MIDI
// audible (bình thường rơi về masterBus.input, mất FX track). // audible (bình thường rơi về masterBus.input, mất FX track).
const _activeTabId=activeTabRef.current;const _activeSub=subTabsRef.current?subTabsRef.current.find(s=>s.id===_activeTabId):null;if(_activeSub&&_activeSub.type==='PIANO_ROLL'){const _prNode=activeTrackNodesRef.current[_activeSub.trackId];if(_prNode&&window.SonicSF&&window.SonicSF.setOutputDestination){if(_prNode.sfEntry){window.SonicSF.setOutputDestination(_prNode.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing (node có const _activeTabId=activeTabRef.current;const _activeSub=subTabsRef.current?subTabsRef.current.find(s=>s.id===_activeTabId):null;if(_activeSub&&_activeSub.type==='PIANO_ROLL'){// PIANO ROLL của track TRONG SECTION (parent_tab_id = session_*): id
// track trùng main track id → lookup key = id trúng node MAIN (route
// theo ♪ main — sai context → MIDI bị kéo dry). ƯU TIÊN SUB-NODE
// (key <parent>_sub_<id> — route theo ♪ section + thẳng masterBus);
// không có sub-node → null (masterBus.input — qua mastering).
let _prNode=activeTrackNodesRef.current[_activeSub.trackId];if(_activeSub.parent_tab_id&&_activeSub.parent_tab_id.startsWith('session_')){const _prSubKeys=Object.keys(activeTrackNodesRef.current).filter(k=>k.endsWith('_sub_'+_activeSub.trackId));_prNode=_prSubKeys.length?activeTrackNodesRef.current[_prSubKeys[0]]:null;}if(_prNode&&window.SonicSF&&window.SonicSF.setOutputDestination){if(_prNode.sfEntry){window.SonicSF.setOutputDestination(_prNode.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing (node có
// thể tạo khi mastering OFF → route dry — sửa ngay nếu chain ON). // thể tạo khi mastering OFF → route dry — sửa ngay nếu chain ON).
if(_prNode.sfRouteGain&&_prNode.sfDryGain){const _prTrk=list.find(t=>t.id===_activeSub.trackId);const _b=effMidiBypass(_prTrk||{id:_activeSub.trackId});_prNode.sfRouteGain.gain.value=_b?0:1;_prNode.sfDryGain.gain.value=_b?1:0;}return;}if(_prNode.gainNode){window.SonicSF.setOutputDestination(_prNode.gainNode);return;}}}const midiAudible=list.filter(t=>t.midiItems&&t.midiItems.length>0&&computeTrackAudibleGain(list,t)>0);if(midiAudible.length===1){const t=midiAudible[0];const node=activeTrackNodesRef.current[t.id];// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when if(_prNode.sfRouteGain&&_prNode.sfDryGain){const _prTrk=list.find(t=>t.id===_activeSub.trackId);const _b=effMidiBypass(_prTrk||{id:_activeSub.trackId});_prNode.sfRouteGain.gain.value=_b?0:1;_prNode.sfDryGain.gain.value=_b?1:0;}return;}if(_prNode.gainNode){window.SonicSF.setOutputDestination(_prNode.gainNode);return;}}}const midiAudible=list.filter(t=>t.midiItems&&t.midiItems.length>0&&computeTrackAudibleGain(list,t)>0);console.log('[SFRoute] activeTab='+_activeTabId,'midiAudible='+midiAudible.map(t=>t.id+'(midi'+(t.midiItems||[]).length+')').join(',')||'none');if(midiAudible.length===1){const t=midiAudible[0];// SECTION-TAB: track id của section clone TRÙNG main track id → node
// chính (key = id) là node MAIN (route theo ♪ main — SAI context, MIDI
// section bị kéo vào dry). ƯU TIÊN SUB-NODE (key <parent>_sub_<id> —
// route theo ♪ section + thẳng masterBus.input) — không có sub-node →
// null (SF về masterBus.input — qua mastering, KHÔNG bypass).
let node=activeTrackNodesRef.current[t.id];if(_activeTabId&&_activeTabId.startsWith('session_')){const subKeys=Object.keys(activeTrackNodesRef.current).filter(k=>k.endsWith('_sub_'+t.id));node=subKeys.length?activeTrackNodesRef.current[subKeys[0]]:null;}// SF ALWAYS enters the track's own FX chain (sfEntry → sfModules when
// PWR ON). The ♪ button only switches the post-FX route (sfRouteGain / // PWR ON). The ♪ button only switches the post-FX route (sfRouteGain /
// sfDryGain) to skip or include the Mastering FX Chain. // sfDryGain) to skip or include the Mastering FX Chain.
if(node&&node.sfEntry&&window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(node.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing. if(node&&node.sfEntry&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=sfEntry node',t.id,'sfRouteGain='+(node.sfRouteGain?node.sfRouteGain.gain.value:'MISSING'),'sfDryGain='+(node.sfDryGain?node.sfDryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.sfEntry);// Ép route qua mastering chain NGAY tại thời điểm routing.
if(node.sfRouteGain&&node.sfDryGain){const _b=effMidiBypass(t.id);node.sfRouteGain.gain.value=_b?0:1;node.sfDryGain.gain.value=_b?1:0;}return;}if(node&&node.gainNode&&window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(node.gainNode);return;}}if(window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(null);}}catch(e){console.warn('updateSfRouting error:',e);}};const updateSfRoutingRef=useRef(null);// ── MIDI preview cache (bounce nhanh offline) ── if(node.sfRouteGain&&node.sfDryGain){// ⚠️ FIX: truyền track OBJECT (t) — trước đây truyền t.id (string)
// → effMidiBypass đọc track.id=undefined → map lookup sai → _b luôn
// false → sfRouteGain bị ép 1 (không bypass) — nút ♪ vô hiệu qua
// đường này.
const _b=effMidiBypass(t);node.sfRouteGain.gain.value=_b?0:1;node.sfDryGain.gain.value=_b?1:0;console.log('[Bypass] sf route sync track',t.id,'midiBypass='+_b,_b?'DRY':'MASTERING');}return;}if(node&&node.gainNode&&window.SonicSF&&window.SonicSF.setOutputDestination){console.log('[SFRoute] dest=gainNode node',t.id,'(no sfEntry) — route.routeGain='+(node.route?node.route.routeGain.gain.value:'MISSING'),'route.dryGain='+(node.route?node.route.dryGain.gain.value:'MISSING'));window.SonicSF.setOutputDestination(node.gainNode);return;}}if(window.SonicSF&&window.SonicSF.setOutputDestination){window.SonicSF.setOutputDestination(null);}}catch(e){console.warn('updateSfRouting error:',e);}};const updateSfRoutingRef=useRef(null);// ── MIDI preview cache (bounce nhanh offline) ──
// Khi preview: capture tiếng đàn (SF output — pre track-FX) vào cache theo // Khi preview: capture tiếng đàn (SF output — pre track-FX) vào cache theo
// track. Khi export: nếu cache đủ độ dài → render OFFLINE NHANH bằng cache // track. Khi export: nếu cache đủ độ dài → render OFFLINE NHANH bằng cache
// (qua FX Rack + Mastering như playback) — không cần chờ bounce realtime. // (qua FX Rack + Mastering như playback) — không cần chờ bounce realtime.
@@ -957,9 +996,19 @@ window.__getTrackSfFxModule=(trackId,type)=>{const node=activeTrackNodesRef.curr
// effAudioBypass (override mastering-ON ép false → route luôn no-bypass // effAudioBypass (override mastering-ON ép false → route luôn no-bypass
// lúc tạo node → nút A phải bấm 2 lần; stop+play → node tái tạo → mất). // lúc tạo node → nút A phải bấm 2 lần; stop+play → node tái tạo → mất).
// RULES: sub-track có CLIPS → nút A (audioBypass); có MIDI → nút ♪ (midiBypass). // RULES: sub-track có CLIPS → nút A (audioBypass); có MIDI → nút ♪ (midiBypass).
const subHasClips=subTrack.clips&&subTrack.clips.length>0||!!subTrack.buffer;const subBypass=subHasClips?trackAudioBypassMap[subTrack.id]!==undefined?!!trackAudioBypassMap[subTrack.id]:!!(subTrack.audioBypass??subTrack.masteringBypass):trackMidiBypassMap[subTrack.id]!==undefined?!!trackMidiBypassMap[subTrack.id]:!!(subTrack.midiBypass??subTrack.masteringBypass);const routeGain=context.createGain();const dryGain=context.createGain();routeGain.gain.value=subBypass?0:1;dryGain.gain.value=subBypass?1:0;gainNode.connect(routeGain);gainNode.connect(dryGain);routeGain.connect(parentNode);if(window.masterBus&&window.masterBus.dryInput){dryGain.connect(window.masterBus.dryInput);}else{dryGain.connect(parentNode);}node={gainNode,pannerNode:null,route:{routeGain:routeGain,dryGain:dryGain,_trackId:subTrack.id}};activeTrackNodesRef.current[subKey]=node;}return node.gainNode;};const playMidiPreviewNote=(pitch,velocity=0.8,durationMs=500)=>{if(!window.SonicSF)return;const context=getAudioContext();const tab=subTabs.find(s=>s.id===activeTabRef.current);const trackId=tab?tab.trackId:selectedTrackId;const track=activeTracks.find(t=>t.id===trackId);const destNode=getOrCreateTrackNode(track,context);const program=track?track.instrumentProgram:undefined;var prevCh=track?assignTrackMidiChannel(track,activeTracks):0;window.SonicSF.playNote(pitch,velocity,durationMs,context.currentTime,program,destNode,prevCh,track?track.synth_engine:undefined);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;// Capture items signature at schedule time — updatePlayhead so sánh với const subHasClips=subTrack.clips&&subTrack.clips.length>0||!!subTrack.buffer;const subBypass=subHasClips?subTrack.audioBypass!==undefined?!!subTrack.audioBypass:trackAudioBypassMap[subTrack.id]!==undefined?!!trackAudioBypassMap[subTrack.id]:false:subTrack.midiBypass!==undefined?!!subTrack.midiBypass:trackMidiBypassMap[subTrack.id]!==undefined?!!trackMidiBypassMap[subTrack.id]:false;const routeGain=context.createGain();const dryGain=context.createGain();routeGain.gain.value=subBypass?0:1;dryGain.gain.value=subBypass?1:0;// analyserNode cho VU meter của SECTION-TAB track strip (VU tick đọc
// node.analyserNode — sub-node trước đây không có → VU không animation).
const subAnalyser=context.createAnalyser();subAnalyser.fftSize=256;subAnalyser.smoothingTimeConstant=0.4;gainNode.connect(subAnalyser);subAnalyser.connect(routeGain);subAnalyser.connect(dryGain);// Mastering path → masterBus.input TRỰC TIẾP (không qua parent node:
// parent có route riêng với bypass riêng — parent audio_bypass=true →
// nối qua parent làm MIDI/audio section bị kéo vào dry bus = bypass
// mastering dù sub-track ♪/A đang tắt).
if(window.masterBus&&window.masterBus.input){routeGain.connect(window.masterBus.input);}else{routeGain.connect(parentNode);}if(window.masterBus&&window.masterBus.dryInput){dryGain.connect(window.masterBus.dryInput);}else{dryGain.connect(parentNode);}node={gainNode,pannerNode:null,analyserNode:subAnalyser,route:{routeGain:routeGain,dryGain:dryGain,_trackId:subTrack.id}};activeTrackNodesRef.current[subKey]=node;}return node.gainNode;};const playMidiPreviewNote=(pitch,velocity=0.8,durationMs=500)=>{if(!window.SonicSF)return;const context=getAudioContext();const tab=subTabs.find(s=>s.id===activeTabRef.current);const trackId=tab?tab.trackId:selectedTrackId;const track=activeTracks.find(t=>t.id===trackId);const destNode=getOrCreateTrackNode(track,context);const program=track?track.instrumentProgram:undefined;var prevCh=track?assignTrackMidiChannel(track,activeTracks):0;window.SonicSF.playNote(pitch,velocity,durationMs,context.currentTime,program,destNode,prevCh,track?track.synth_engine:undefined);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;// Capture items signature at schedule time — updatePlayhead so sánh với
// signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule). // signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule).
scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING'));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;console.log('[Play] track',track.id,track.name,'node ok:',!!(gainNode&&pannerNode),'muted:',track.muted);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(offsetTime<clipStart){const delay=clipStart-offsetTime;source.start(context.currentTime+delay,0);activeSourcesRef.current.push(source);}else if(offsetTime<clipEnd){const playOffset=offsetTime-clipStart;source.start(context.currentTime,playOffset*(clip.speed||1.0));activeSourcesRef.current.push(source);}});// MIDI items playback scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING'));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;// Re-apply route gains theo content-type NGAY tại thời điểm play — node
// có thể STALE (tạo từ MAIN play với track cùng id — route theo data cũ)
// → first play sau double-click vào SECTION-TAB bị bypass. Re-apply
// routeBypassFor(track) mỗi lần schedule sẽ hết chu kỳ "stop+play mới đúng".
const playNode=activeTrackNodesRef.current[track.id];if(playNode&&playNode.route&&playNode.route.routeGain){const rb=routeBypassFor(track);playNode.route.routeGain.gain.value=rb?0:1;playNode.route.dryGain.gain.value=rb?1:0;console.log('[Bypass] reapply track',track.id,'contentType='+(routeBypassFor._lastType?'midi':'audio'),'bypass='+rb,'→ routeGain='+playNode.route.routeGain.gain.value+'/dryGain='+playNode.route.dryGain.gain.value);}console.log('[Play] track',track.id,track.name,'node ok:',!!(gainNode&&pannerNode),'muted:',track.muted);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(offsetTime<clipStart){const delay=clipStart-offsetTime;source.start(context.currentTime+delay,0);activeSourcesRef.current.push(source);}else if(offsetTime<clipEnd){const playOffset=offsetTime-clipStart;source.start(context.currentTime,playOffset*(clip.speed||1.0));activeSourcesRef.current.push(source);}});// MIDI items playback
const midiItems=track.midiItems||[];if((track.type==='MIDI'||midiItems.length>0)&&window.SonicSF){// Preview cache: capture the soundfont (pre track-FX) for fast offline export const midiItems=track.midiItems||[];if((track.type==='MIDI'||midiItems.length>0)&&window.SonicSF){// Preview cache: capture the soundfont (pre track-FX) for fast offline export
ensureMidiCapture(track,activeTrackNodesRef.current[track.id]);var trkCh=assignTrackMidiChannel(track,allPlayTracks);// Ensure instrument is loaded in FluidSynth ensureMidiCapture(track,activeTrackNodesRef.current[track.id]);var trkCh=assignTrackMidiChannel(track,allPlayTracks);// Ensure instrument is loaded in FluidSynth
if(track.synth_engine&&track.synth_engine.type==='soundfont'&&track.synth_engine.soundfont_id){window.SonicSF.selectInstrument(trkCh,track.synth_engine.soundfont_bank||0,track.synth_engine.soundfont_program||0,track.synth_engine.soundfont_id);}const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;midiItems.forEach(item=>{const itemEndSec=item.startTime+(item.duration||4);const notes=item.notes||[];notes.forEach(note=>{// note start/duration is in beats (for MIDI items) if(track.synth_engine&&track.synth_engine.type==='soundfont'&&track.synth_engine.soundfont_id){window.SonicSF.selectInstrument(trkCh,track.synth_engine.soundfont_bank||0,track.synth_engine.soundfont_program||0,track.synth_engine.soundfont_id);}const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;midiItems.forEach(item=>{const itemEndSec=item.startTime+(item.duration||4);const notes=item.notes||[];notes.forEach(note=>{// note start/duration is in beats (for MIDI items)
@@ -970,11 +1019,18 @@ if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(track.id,note.velo
if(track.instrumentId&&midiItems.length===0&&track.buffer){// Play audio normally (the buffer has the audio data) if(track.instrumentId&&midiItems.length===0&&track.buffer){// Play audio normally (the buffer has the audio data)
// This block is intentionally empty - audio clips already play above // This block is intentionally empty - audio clips already play above
}// SECTION items playback }// SECTION items playback
const sections=track.sections||[];if(activeTab==='main'&&sections.length>0){const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;sections.forEach(sec=>{const secStart=sec.start||0;const secEnd=secStart+(sec.duration||0);const openTab=sessionTabsRef.current?sessionTabsRef.current.find(st=>st.sectionId===sec.sectionId||st.sectionId===sec.id):null;const secTracks=openTab?openTab.tracks:sec.tracks||[];const hasSubSolo=secTracks.some(st=>st.solo);secTracks.forEach((subTrack,subIdx)=>{const isSubPlayable=hasSubSolo?subTrack.solo:!subTrack.muted;if(!isSubPlayable)return;const subNode=getOrCreateSubTrackNode(track,subTrack,context);if(!subNode)return;// 1. Play clips in subTrack const sections=track.sections||[];if(activeTab==='main'&&sections.length>0){const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;sections.forEach(sec=>{const secStart=sec.start||0;const secEnd=secStart+(sec.duration||0);const openTab=sessionTabsRef.current?sessionTabsRef.current.find(st=>st.sectionId===sec.sectionId||st.sectionId===sec.id):null;const secTracks=openTab?openTab.tracks:sec.tracks||[];const hasSubSolo=secTracks.some(st=>st.solo);secTracks.forEach((subTrack,subIdx)=>{const isSubPlayable=hasSubSolo?subTrack.solo:!subTrack.muted;if(!isSubPlayable)return;const subNode=getOrCreateSubTrackNode(track,subTrack,context);if(!subNode)return;// Re-apply route sub-node theo content-type mỗi lần schedule —
// sub-node có thể STALE (route theo trạng thái cũ) → midi section
// bị kéo vào dry. routeBypassFor: MIDI-only → ♪, clips → A.
const subNodeObj=activeTrackNodesRef.current[track.id+'_sub_'+subTrack.id];if(subNodeObj&&subNodeObj.route&&subNodeObj.route.routeGain){const srb=routeBypassFor(subTrack);subNodeObj.route.routeGain.gain.value=srb?0:1;subNodeObj.route.dryGain.gain.value=srb?1:0;}// 1. Play clips in subTrack
const subClips=subTrack.clips||[];subClips.forEach(clip=>{if(!clip.buffer)return;const clipStartLocal=clip.startTime||0;const clipDurLocal=clip.buffer.duration/(clip.speed||1.0);const clipStartMain=secStart+clipStartLocal;const clipEndMain=clipStartMain+clipDurLocal;// Only play within the bounds of [secStart, secEnd] const subClips=subTrack.clips||[];subClips.forEach(clip=>{if(!clip.buffer)return;const clipStartLocal=clip.startTime||0;const clipDurLocal=clip.buffer.duration/(clip.speed||1.0);const clipStartMain=secStart+clipStartLocal;const clipEndMain=clipStartMain+clipDurLocal;// Only play within the bounds of [secStart, secEnd]
const activeStart=Math.max(clipStartMain,secStart);const activeEnd=Math.min(clipEndMain,secEnd);if(activeStart<activeEnd&&offsetTime<activeEnd){const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(subNode);if(offsetTime<activeStart){const delay=activeStart-offsetTime;const playOffset=activeStart-clipStartMain;const playDuration=activeEnd-activeStart;source.start(context.currentTime+delay,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}else{const playOffset=offsetTime-clipStartMain;const playDuration=activeEnd-offsetTime;source.start(context.currentTime,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}}});// 2. Play MIDI items in subTrack const activeStart=Math.max(clipStartMain,secStart);const activeEnd=Math.min(clipEndMain,secEnd);if(activeStart<activeEnd&&offsetTime<activeEnd){const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(subNode);if(offsetTime<activeStart){const delay=activeStart-offsetTime;const playOffset=activeStart-clipStartMain;const playDuration=activeEnd-activeStart;source.start(context.currentTime+delay,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}else{const playOffset=offsetTime-clipStartMain;const playDuration=activeEnd-offsetTime;source.start(context.currentTime,playOffset*(clip.speed||1.0),playDuration);activeSourcesRef.current.push(source);}}});// 2. Play MIDI items in subTrack
const subMidiItems=subTrack.midiItems||[];if(window.SonicSF&&subMidiItems.length>0){subMidiItems.forEach(item=>{const notes=item.notes||[];notes.forEach(note=>{const noteStartSecLocal=item.startTime+(note.start_beat||0)*secondsPerBeat;const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteStartMain=secStart+noteStartSecLocal;const noteEndMain=noteStartMain+noteDurSec;// Only play if the note starts inside the Section item bounds and hasn't finished yet const subMidiItems=subTrack.midiItems||[];if(window.SonicSF&&subMidiItems.length>0){subMidiItems.forEach(item=>{const notes=item.notes||[];notes.forEach(note=>{const noteStartSecLocal=item.startTime+(note.start_beat||0)*secondsPerBeat;const noteDurSec=(note.duration_beats||1)*secondsPerBeat;const noteStartMain=secStart+noteStartSecLocal;const noteEndMain=noteStartMain+noteDurSec;// Only play if the note starts inside the Section item bounds and hasn't finished yet
if(noteStartMain>=secStart&&noteStartMain<secEnd&&offsetTime<noteEndMain){const notePlayEndMain=Math.min(noteEndMain,secEnd);const program=subTrack.instrumentProgram;var subCh=assignTrackMidiChannel(subTrack,secTracks);if(offsetTime<noteStartMain){const delay=noteStartMain-offsetTime;const startTime=context.currentTime+delay;const playDurMs=(notePlayEndMain-noteStartMain)*1000;window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,playDurMs,startTime,program,subNode,subCh,subTrack.synth_engine);}else{const remainingDurMs=(notePlayEndMain-offsetTime)*1000;window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,remainingDurMs,context.currentTime,program,subNode,subCh,subTrack.synth_engine);}}});});}});});}});updateSfRouting();};// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) if(noteStartMain>=secStart&&noteStartMain<secEnd&&offsetTime<noteEndMain){const notePlayEndMain=Math.min(noteEndMain,secEnd);const program=subTrack.instrumentProgram;var subCh=assignTrackMidiChannel(subTrack,secTracks);if(offsetTime<noteStartMain){const delay=noteStartMain-offsetTime;const startTime=context.currentTime+delay;const playDurMs=(notePlayEndMain-noteStartMain)*1000;window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,playDurMs,startTime,program,subNode,subCh,subTrack.synth_engine);}else{const remainingDurMs=(notePlayEndMain-offsetTime)*1000;window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,remainingDurMs,context.currentTime,program,subNode,subCh,subTrack.synth_engine);}// Trigger VU meter flash đúng nhịp (giống main-track midi 19066)
// Cả subTrack (VU section tab) + parent track (VU MAIN — section
// item đang play) — midiVuActivity keyed theo track id → trigger
// cả 2 để VU main nhảy theo section.
if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(subTrack.id,note.velocity||0.8);window.triggerMidiVuActivity(track.id,note.velocity||0.8);}}});});}});});}});updateSfRouting();};// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
const startLocalTrackPlayback=(trackId,offsetTime)=>{const context=getAudioContext();const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Get or create persistent gain & panner for real-time control const startLocalTrackPlayback=(trackId,offsetTime)=>{const context=getAudioContext();const track=tracks.find(t=>t.id===trackId);if(!track)return;const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Get or create persistent gain & panner for real-time control
const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);// NOTE: do NOT connect gainNode → pannerNode directly here — the track const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;clips.forEach(clip=>{if(!clip.buffer)return;const source=context.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(gainNode);// NOTE: do NOT connect gainNode → pannerNode directly here — the track
// node already wires gainNode → fxEntry → [FX modules] → fxLegacyIn → // node already wires gainNode → fxEntry → [FX modules] → fxLegacyIn →
@@ -1211,8 +1267,13 @@ const masterVUAnimRef=useRef(null);useEffect(()=>{function tick(){try{// Khi STO
// activity → hết animation dính + master không full sau khi dừng play. // activity → hết animation dính + master không full sau khi dừng play.
const isRecActive=activeAudioRecordersRef.current&&Object.keys(activeAudioRecordersRef.current).length>0;if(!isPlaying&&!isRecActive){setMasterVU(0);setMasterMeterPeak(0);Object.keys(trackVuRefs.current).forEach(k=>{const c=trackVuRefs.current[k];if(!c)return;if(k.endsWith('_mixer'))drawMixerVuMeter(c,0);else drawVuMeter(c,-60);});masterVUAnimRef.current=requestAnimationFrame(tick);return;}// 1. Master VU Meter const isRecActive=activeAudioRecordersRef.current&&Object.keys(activeAudioRecordersRef.current).length>0;if(!isPlaying&&!isRecActive){setMasterVU(0);setMasterMeterPeak(0);Object.keys(trackVuRefs.current).forEach(k=>{const c=trackVuRefs.current[k];if(!c)return;if(k.endsWith('_mixer'))drawMixerVuMeter(c,0);else drawVuMeter(c,-60);});masterVUAnimRef.current=requestAnimationFrame(tick);return;}// 1. Master VU Meter
if(masterBus&&masterBus.analyser){const data=new Uint8Array(128);masterBus.analyser.getByteTimeDomainData(data);let peak=0;for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>peak)peak=v;}setMasterVU(peak);setMasterMeterPeak(prev=>Math.max(prev*0.97,peak));}// 2. Track VU Meters if(masterBus&&masterBus.analyser){const data=new Uint8Array(128);masterBus.analyser.getByteTimeDomainData(data);let peak=0;for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>peak)peak=v;}setMasterVU(peak);setMasterMeterPeak(prev=>Math.max(prev*0.97,peak));}// 2. Track VU Meters
const trackNodes=activeTrackNodesRef.current||{};const activeKeys=Object.keys(trackVuRefs.current);activeKeys.forEach(key=>{const trackId=key.replace('_mixer','');const isRecordingThisTrack=activeAudioRecordersRef.current&&activeAudioRecordersRef.current[trackId];if(isRecordingThisTrack)return;const node=trackNodes[trackId];const canvas=trackVuRefs.current[key];if(!canvas)return;// Only animate when real audio actually passes through the track (solo/mute aware) const trackNodes=activeTrackNodesRef.current||{};const activeKeys=Object.keys(trackVuRefs.current);activeKeys.forEach(key=>{const trackId=key.replace('_mixer','');const isRecordingThisTrack=activeAudioRecordersRef.current&&activeAudioRecordersRef.current[trackId];if(isRecordingThisTrack)return;const node=trackNodes[trackId];const canvas=trackVuRefs.current[key];if(!canvas)return;// Section tab: track nodes nằm dưới key <parent>_sub_<trackId> —
var vuTracks=activeTracksRef.current||[];var anySoloVU=vuTracks.some(function(t){return t.solo;});var vuTrack=vuTracks.find(function(t){return t.id===trackId;});var isAudible=vuTrack?anySoloVU?!!vuTrack.solo:!vuTrack.muted:true;let audioPeak=0;if(isAudible&&node&&node.analyserNode){const analyser=node.analyserNode;const data=new Uint8Array(128);analyser.getByteTimeDomainData(data);for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>audioPeak)audioPeak=v;}}// midiVuActivityRef được set bởi triggerMidiVuActivity — cả khi play // lookup plain key trả undefined → VU không animation. Fallback scan _sub_.
const vNode=node||function(){for(var k in trackNodes){if(k.endsWith('_sub_'+trackId))return trackNodes[k];}return null;}();// Only animate when real audio actually passes through the track (solo/mute aware)
var vuTracks=activeTracksRef.current||[];var anySoloVU=vuTracks.some(function(t){return t.solo;});var vuTrack=vuTracks.find(function(t){return t.id===trackId;});var isAudible=vuTrack?anySoloVU?!!vuTrack.solo:!vuTrack.muted:true;let audioPeak=0;if(isAudible&&vNode&&vNode.analyserNode){const analyser=vNode.analyserNode;const data=new Uint8Array(128);analyser.getByteTimeDomainData(data);for(let i=0;i<data.length;i++){const v=Math.abs(data[i]-128)/128;if(v>audioPeak)audioPeak=v;}}// Section item trên MAIN track: VU theo sub-nodes của track này (key
// <trackId>_sub_*) — âm section đi thẳng masterBus.input (không qua
// node chính) → node chính không có tín hiệu → VU track đứng yên.
if(audioPeak<=0.001){for(var sk in trackNodes){if(sk.indexOf(trackId+'_sub_')===0){const sn=trackNodes[sk];if(sn&&sn.analyserNode){const d2=new Uint8Array(128);sn.analyserNode.getByteTimeDomainData(d2);for(let i=0;i<d2.length;i++){const v=Math.abs(d2[i]-128)/128;if(v>audioPeak)audioPeak=v;}}}}}// midiVuActivityRef được set bởi triggerMidiVuActivity — cả khi play
// MIDI item LẪN khi ARM + nhấn phím MIDI keyboard (preview) — nên VU // MIDI item LẪN khi ARM + nhấn phím MIDI keyboard (preview) — nên VU
// nhảy trong cả 2 trường hợp (không chặn bởi isPlaying). // nhảy trong cả 2 trường hợp (không chặn bởi isPlaying).
let midiPeak=isAudible?midiVuActivityRef.current[trackId]||0:0;if(midiPeak>0){midiVuActivityRef.current[trackId]=midiPeak*0.90;if(midiVuActivityRef.current[trackId]<0.01){midiVuActivityRef.current[trackId]=0;}}const peak=Math.max(audioPeak,midiPeak);const db=peak>0?20*Math.log10(peak):-60;if(peak>0.001){if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,peak);}else{drawVuMeter(canvas,db);}}else{if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,0);}else{drawVuMeter(canvas,-60);}}});}catch(e){console.warn('VU tick error:',e);}masterVUAnimRef.current=requestAnimationFrame(tick);}masterVUAnimRef.current=requestAnimationFrame(tick);return()=>{if(masterVUAnimRef.current)cancelAnimationFrame(masterVUAnimRef.current);};},[isPlaying]);const handleOpenProject=async(projectId,projectName)=>{setAppWarningModal({title:"Mở dự án",message:"Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.",isAlert:false,onConfirm:async()=>{try{const proj=await window.SonicAPI.getCloudProject(projectId);const parsed=JSON.parse(proj.data_json);let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(parsed.main_session){const result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;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){const rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(proj.name);setCurrentProjectId(proj.id);if(restoredSessionTabs.length>0)setSessionTabs(restoredSessionTabs);if(restoredSubTabs.length>0)setSubTabs(restoredSubTabs);localStorage.setItem('sonic_project_name',proj.name);localStorage.setItem('sonic_project_id',proj.id);showToast('Đã nạp dự án "'+proj.name+'" thành công!',"success");}catch(e){showToast(e.message||"Lỗi khi nạp dự án","error");}}});};return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"h-full w-full flex flex-col bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("header",{className:"h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none"},[{label:'File',items:[{label:'New Project',icon:'file-plus',shortcut:'Ctrl+N',action:()=>{// New project: đóng hết tab + reset MỌI trạng thái về mặc định let midiPeak=isAudible?midiVuActivityRef.current[trackId]||0:0;if(midiPeak>0){midiVuActivityRef.current[trackId]=midiPeak*0.90;if(midiVuActivityRef.current[trackId]<0.01){midiVuActivityRef.current[trackId]=0;}}const peak=Math.max(audioPeak,midiPeak);const db=peak>0?20*Math.log10(peak):-60;if(peak>0.001){if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,peak);}else{drawVuMeter(canvas,db);}}else{if(key.endsWith('_mixer')){drawMixerVuMeter(canvas,0);}else{drawVuMeter(canvas,-60);}}});}catch(e){console.warn('VU tick error:',e);}masterVUAnimRef.current=requestAnimationFrame(tick);}masterVUAnimRef.current=requestAnimationFrame(tick);return()=>{if(masterVUAnimRef.current)cancelAnimationFrame(masterVUAnimRef.current);};},[isPlaying]);const handleOpenProject=async(projectId,projectName)=>{setAppWarningModal({title:"Mở dự án",message:"Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.",isAlert:false,onConfirm:async()=>{try{const proj=await window.SonicAPI.getCloudProject(projectId);const parsed=JSON.parse(proj.data_json);let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(parsed.main_session){const result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;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){const rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(proj.name);setCurrentProjectId(proj.id);if(restoredSessionTabs.length>0)setSessionTabs(restoredSessionTabs);if(restoredSubTabs.length>0)setSubTabs(restoredSubTabs);localStorage.setItem('sonic_project_name',proj.name);localStorage.setItem('sonic_project_id',proj.id);showToast('Đã nạp dự án "'+proj.name+'" thành công!',"success");}catch(e){showToast(e.message||"Lỗi khi nạp dự án","error");}}});};return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{className:"h-full w-full flex flex-col bg-[#1e1e1e]"},/*#__PURE__*/React.createElement("header",{className:"h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none"},[{label:'File',items:[{label:'New Project',icon:'file-plus',shortcut:'Ctrl+N',action:()=>{// New project: đóng hết tab + reset MỌI trạng thái về mặc định
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <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/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608062345" defer></script> <script src="/static/js/app.precompiled.js?v=202608070345" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+89
View File
@@ -2272,3 +2272,92 @@
- **FIX (app.jsx):** truyền `active={showMediaExplorer}` vào MediaExplorerPanel + effect `if (active === false) stopMediaPlayback()` — đóng F6 → dừng source + loop + SonicSF.stopAll. - **FIX (app.jsx):** truyền `active={showMediaExplorer}` vào MediaExplorerPanel + effect `if (active === false) stopMediaPlayback()` — đóng F6 → dừng source + loop + SonicSF.stopAll.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062345 — sửa typo `</script></script>` kép), `wiki.md`. Rebuild precompiled. - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608062345 — sửa typo `</script></script>` kép), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → F6 → preview 1 file → F6 close → âm dừng ngay. - **Ghi chú/Test:** `npm run build` → hard refresh → F6 → preview 1 file → F6 close → âm dừng ngay.
### [2026-08-07 00:00] Task: VU meter SECTION-TAB không animation khi play
- **Báo cáo user:** play ở MAIN → chuyển SECTION-TAB check item đang play → VU meter không animation realtime.
- **Nguyên nhân (2 lỗi):** (1) VU tick (24603) lookup `trackNodes[trackId]` — key THƯỜNG — node của section tab là SUB-NODE (key `<parent>_sub_<trackId>`) → không khớp → node undefined; (2) sub-node KHÔNG có `analyserNode` (chỉ `{gainNode, pannerNode, route}`) → VU không có gì để đọc.
- **FIX (app.jsx):** (1) VU tick — fallback scan `_sub_<trackId>` keys → vNode; đọc analyser từ `vNode.analyserNode`; (2) getOrCreateSubTrackNode — thêm `analyserNode` (gainNode → analyser → routeGain/dryGain) → VU đọc được tín hiệu section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070000), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → chuyển SECTION-TAB → VU meters nhảy realtime theo track đang play.
### [2026-08-07 00:30] Task: MIDI section bị bypass mastering (route qua parent) + VU section không đúng nhịp
- **Báo cáo user:** play MAIN → nhấp đôi vào section item → (1) VU meter animation không đúng nhịp âm thanh; (2) âm thanh MIDI item bị bypass qua mastering chain.
- **Nguyên nhân:**
(1) **MIDI bypass**: sub-node routeGain nối vào PARENT NODE → qua route của parent track (parent `audio_bypass: true` → routeGain 0/dryGain 1) → MIDI/audio section bị kéo vào DRY bus dù sub-track ♪/A tắt → "bị bypass mastering".
(2) **VU không đúng nhịp**: scheduling MIDI của section item KHÔNG trigger `triggerMidiVuActivity` (main-track midi có ở 19066) → VU chỉ dựa analyser (trễ/smoothing) → không nhảy theo từng note.
- **FIX (app.jsx):**
(1) getOrCreateSubTrackNode — mastering path `routeGain.connect(window.masterBus.input)` TRỰC TIẾP (không qua parent — parent route có bypass riêng); fallback parentNode khi không có masterBus.
(2) Section midi scheduling (19182) — thêm `triggerMidiVuActivity(subTrack.id, note.velocity)` sau mỗi playNote.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070030), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → nhấp đôi section → MIDI qua mastering (nghe EQ/limiter tác dụng) + VU nhảy đúng theo note.
### [2026-08-07 01:00] Task: MIDI section tab bị bypass — createMasteringRoute dùng audioBypass cho MIDI-only track
- **Báo cáo user (kèm log):** nhấp đôi section → `setOutputDestination to: track node (sfEntry)` + noteon chạy nhưng MIDI bị bypass mastering.
- **Nguyên nhân:** SECTION-TAB play dùng PLAIN node (getOrCreateTrackNode) → `createMasteringRoute` đọc `trackAudioBypassMap`/`track.audioBypass` — section track default `audio_bypass: true` (round 48) → route dry → **MIDI kéo vào dry bus dù ♪ tắt**. (sfBypass/SF route đã đúng ♪ ở 1041/18667 — chỉ AUDIO route sai.)
- **FIX (app.jsx createMasteringRoute):** bypass theo LOẠI nội dung — track **MIDI-only** (midiItems, không clips/buffer) → dùng `trackMidiBypassMap`/`midiBypass` (♪); track có clips → `audioBypass` (A) như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070100), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → nhấp đôi section → MIDI qua mastering (EQ/limiter nghe rõ); log `[Bypass]` routeGain=1/dryGain=0 cho midi track.
### [2026-08-07 01:30] Task: First play sau double-click bị bypass (node STALE) + VU track chứa section item
- **Báo cáo user:** (1) nhấp đôi section → play lần ĐẦU midi bị bypass; stop + play lại → đúng. (2) play section item trên track → VU track đó không animation.
- **Nguyên nhân:**
(1) **Node STALE**: section tab track id TRÙNG main track id — node tạo từ MAIN play (route theo data main — audioBypass=true → dry) được cache lại → first play trong section tab dùng node cũ → bypass; stop (wipe nodes) → play lại → node mới đúng.
(2) **VU track main**: âm section đi thẳng masterBus.input (fix 00:30) → KHÔNG qua node chính → analyser node chính không có tín hiệu → VU đứng yên.
- **FIX (app.jsx):**
(1) Helper `routeBypassFor(track)` (bypass theo content-type — MIDI-only → ♪, có clips → A) — áp: createMasteringRoute (đã có), startTrackPlayback re-apply route mỗi lần schedule (hết stale), mastering-settings effect (14906 thay effAudioBypass).
(2) VU tick — scan sub-nodes (`k.indexOf(trackId + '_sub_') === 0`) khi node chính không có tín hiệu → peak sub-node analyser → VU track main animation theo section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070130), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → nhấp đôi section → play lần ĐẦU → midi qua mastering ngay; play section trên MAIN → VU track đó nhảy.
### [2026-08-07 02:00] Task: VU MAIN không nhảy cho midi section item — triggerMidiVuActivity thiếu parent
- **Báo cáo user:** (1) first play sau double-click midi vẫn bị bypass (lặp — cần xác nhận bundle 0130); (2) quay về MAIN — section item play midi notes → VU meter không animation.
- **Nguyên nhân (2):** midiVuActivity keyed theo `subTrack.id` (section) — VU MAIN đọc `midiVuActivityRef[mainTrackId]` → miss → VU main đứng yên.
- **FIX (app.jsx 19212):** section midi scheduling trigger CẢ 2 — `triggerMidiVuActivity(subTrack.id, ...)` + `triggerMidiVuActivity(track.id, ...)` (parent/main track) → VU main nhảy theo section item đang play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070200), `wiki.md`. Rebuild precompiled.
- **Chờ xác nhận bug (1):** user build + test 0130/0200 — nếu first play VẪN bypass → dán console: `[Bypass] node created` (routeGain/dryGain) + `setOutputDestination` lúc nhấp đôi + play lần đầu.
### [2026-08-07 02:30] Task: Re-apply route sub-node khi section item play (main) — hết stale bypass
- **Yêu cầu user:** (1) BẮT BUỘC VU meter hiển thị trên track đang play items (MAIN + SECTION-TAB); (2) chuyển tab MAIN → SECTION-TAB midi item đang play KHÔNG được bypass.
- **FIX (app.jsx 19132):** section item play (main) — sau getOrCreateSubTrackNode — re-apply route sub-node theo `routeBypassFor(subTrack)` (MIDI-only → ♪, clips → A) mỗi lần schedule → hết stale route (tương tự re-apply plain node ở 0130).
- **VU stack hiện tại (các fix trước):** sub-node có analyserNode (00:00) → VU section tab đọc sub-node (00:00); trigger midi VU cho section (00:30); VU main scan sub-nodes khi node chính câm (01:30); trigger parent track cho VU main (02:00). **BẮT BUỘC hiển thị = đã phủ: main track (subPeak + midi activity) + section tab (vNode sub-node + midi activity).**
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070230), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → (1) play section ở MAIN + trong SECTION-TAB → VU nhảy đúng track; (2) MAIN → double-click SECTION-TAB → play lần đầu → MIDI qua mastering (không bypass).
### [2026-08-07 03:30] Task: MIDI section bị bypass khi vào SECTION-TAB — masteringBypass fallback + clone kế thừa flag
- **Báo cáo user:** đang play MAIN SESSION → double-click section item vào SECTION-TAB → âm MIDI item bị bypass (không qua mastering chain).
- **Nguyên nhân (data thật — project "Section test" trong DB):** sub-track section (id TRÙNG main track id 1, 2) lưu `mastering_bypass=True` + `audio_bypass=True` (kế thừa từ track gốc). Mọi hàm quyết định bypass (`effMidiBypass`/`effAudioBypass`/`createMasteringRoute`/`routeBypassFor`/`getOrCreateSubTrackNode`) fallback `?? track.masteringBypass` → khi map chưa sync/thiếu entry (timing khi double-click + play lần đầu) → track MIDI-only bị tính bypass=TRUE → SF/sub-node route vào DRY bus (bypass mastering) dù ♪ tắt. Ngoài ra `handleEditSectionInTab` clone section tracks GIỮ NGUYÊN audioBypass/masteringBypass của track gốc → sync effect (14757) ghi map audio sai cho track section.
- **FIX (app.jsx):**
(1) BỎ fallback `?? masteringBypass` khỏi mọi hàm quyết định route (effMidiBypass/effAudioBypass/createMasteringRoute/routeBypassFor/getOrCreateSubTrackNode) — nút A/♪ + `audioBypass`/`midiBypass` là nguồn duy nhất; `masteringBypass` không còn quyết định route (field legacy kế thừa sai).
(2) `handleEditSectionInTab` (16595) — clone section tracks ép reset bypass theo context: `masteringBypass:false, audioBypass:true (section raw default), midiBypass:false` — hết kế thừa flag track gốc.
(3) Sync effect (14757): `trackMasteringBypassMap[t.id] = !!t.masteringBypass` (trước gán nhầm `!!t.audioBypass`).
(4) `updateSfRouting` (18813): `effMidiBypass(t.id)``effMidiBypass(t)` — truyền string → map lookup sai → sfRouteGain bị ép 1 (♪ vô hiệu qua đường này).
(5) Log chẩn đoán: `[Bypass] sfEntry created track <id> midiBypass=...` (lúc tạo node) + `[Bypass] sf route sync track <id> ...` (updateSfRouting ép route) — test còn lỗi thì dán log.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070330), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** `npm run build` → hard refresh → play MAIN → double-click section vào SECTION-TAB → MIDI qua mastering (nghe EQ/limiter); play trong SECTION-TAB → MIDI vẫn qua mastering (♪ tắt); bật ♪ → bypass đúng ý. Nếu còn bypass → dán console `[Bypass]` logs.
### [2026-08-07 03:35] Task: MIDI section vẫn bypass sau 0330 — thêm log chẩn đoán [SFRoute]/[Bypass] reapply
- **Báo cáo user (log 0330):** play MAIN → double-click section → `[SonicSF] setOutputDestination to: track node (sfEntry)` + noteons chạy → bấm Play tab (isPlaying=true) → stopAllPlayback → SF về masterBus.input. Log THIẾU các dòng `[Bypass]`/`[Play] offset` của bản 0330 → chưa xác định được route thực tế (sfRouteGain/routeGain giá trị bao nhiêu lúc nghe bypass).
- **Log mới thêm (app.jsx):**
(1) `[SFRoute] activeTab=... midiAudible=...` — updateSfRouting: track nào audible + đích tính.
(2) `[SFRoute] dest=sfEntry node <id> sfRouteGain=... sfDryGain=...` — đích SF + giá trị route hiện tại TRƯỚC khi ép.
(3) `[SFRoute] dest=gainNode node <id> (no sfEntry) route.routeGain=... dryGain=...` — đích fallback gainNode.
(4) `[Bypass] reapply track <id> contentType=midi/audio bypass=... → routeGain=.../dryGain=...` — startTrackPlayback re-apply route.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070335), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section vào SECTION-TAB → nghe bypass → **dán TOÀN BỘ console (không filter)** — cần các dòng `[SFRoute]`, `[Bypass]`, `[Play] offset`, `setOutputDestination` để chốt đường bypass chính xác.
### [2026-08-07 03:40] Task: MIDI section bypass — ID TRÙNG main track → node/map collision (CHỐT NGUYÊN NHÂN)
- **Log 0335 (đã chốt):** play MAIN → `[SFRoute] activeTab=main midiAudible=` (rỗng → SF về masterBus.input — MIDI qua mastering ✓). Double-click → `[SFRoute] activeTab=session_... midiAudible=1(midi1)``[SFRoute] dest=gainNode node 1 (no sfEntry) — route.routeGain=0 route.dryGain=1` → SF route vào NODE '1' → **route dry → MIDI section BỊ BYPASS**. (Lưu ý: log soundfontPlayer "track node (sfEntry)" là log SAI — luôn in "sfEntry" cho mọi node khác null; đích thực là gainNode.)
- **Nguyên nhân gốc:** section clone giữ id sub-track TRÙNG main track id (data: main 1/2 = sub 1/2). (1) `updateSfRouting` lookup node theo id → trúng node MAIN (route theo ♪ MAIN = bật = dry) → SF (MIDI section) kéo vào dry dù ♪ section tắt. (2) Bypass map keyed theo id bị GHI ĐÈ CHÉO (clone audioBypass=true ghi đè map main; ♪ main ảnh hưởng route section).
- **FIX (app.jsx):**
(1) MỌI hàm quyết định route (`effMidiBypass`/`effAudioBypass`/`createMasteringRoute`/`routeBypassFor`/sub-node): **ƯU TIÊN field track object (`track.midiBypass`/`track.audioBypass`)** — object phân biệt đúng context; map chỉ fallback (realtime nút bấm, track không field).
(2) `updateSfRouting`: activeTab là `session_*` → lookup node ƯU TIÊN SUB-NODE (key `<parent>_sub_<id>` — route theo ♪ section + thẳng masterBus.input); không có sub-node → null (SF về masterBus.input — qua mastering, KHÔNG bypass). KHÔNG bao giờ route vào node MAIN khi ở section tab.
(3) Sync effect (14754): đảo thứ tự — session/clone TRƯỚC, MAIN SAU (main thắng khi id trùng — map fallback đúng context; clone dùng field).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070340), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section vào SECTION-TAB → MIDI qua mastering (nghe EQ/limiter); log kỳ vọng: `[SFRoute] dest=gainNode node 1 ...` → KHÔNG còn (sub-node hoặc masterBus.input); `route.routeGain=1`. Play section tab → MIDI qua mastering (♪ tắt); bật ♪ section → bypass đúng ý.
### [2026-08-07 03:45] Task: MIDI bypass khi mở MIDI item SECTION-TAB → PIANO ROLL TAB
- **Báo cáo user:** sau fix 0340 (section tab OK) — mở MIDI item từ SECTION-TAB vào PIANO ROLL TAB → MIDI bị bypass.
- **Nguyên nhân:** cùng cơ chế collision id — PIANO ROLL của track section có `parent_tab_id = session_*`, `trackId` = id sub-track TRÙNG main track id. `updateSfRouting` nhánh PIANO_ROLL lookup `activeTrackNodesRef.current[trackId]` → trúng NODE MAIN (route theo ♪ main — dry) → SF (notes piano roll) kéo vào dry dù ♪ section tắt. Xảy ra ngay khi ĐỔI TAB (effect 14814 → updateSfRouting) — không cần play.
- **FIX (app.jsx updateSfRouting nhánh PIANO_ROLL):** khi `_activeSub.parent_tab_id` startsWith `session_` → lookup node ƯU TIÊN SUB-NODE (key `endsWith('_sub_' + trackId)` — route theo ♪ section + thẳng masterBus.input); không có sub-node → null (SF về masterBus.input — qua mastering, KHÔNG bypass). Không bao giờ route vào node MAIN cho piano roll của section.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070345), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → double-click section (SECTION-TAB) → double-click MIDI item → PIANO ROLL tab → MIDI qua mastering (nghe EQ/limiter); log `[SFRoute]` không còn dest node MAIN (route 0/1) cho piano roll section.