From 8cdf2942b9d41c4effc6ea051eacb02ad206c7a6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 7 Aug 2026 09:13:03 +0700 Subject: [PATCH] =?UTF-8?q?FIX:=20s=E1=BB=ADa=20l=E1=BB=97i=20chuy?= =?UTF-8?q?=E1=BB=83n=20t=E1=BB=AB=20tab=20MAIN=20SESSION=20sang=20SECTION?= =?UTF-8?q?-TAB=20v=C3=A0=20PIANO=20ROLL=20TAB=20th=C3=AC=20b=E1=BB=8B=20b?= =?UTF-8?q?ypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/js/app.jsx | 181 +++++++++++++++++++++++++++---- app/static/js/app.precompiled.js | 99 +++++++++++++---- app/templates/index.html | 2 +- wiki.md | 89 +++++++++++++++ 4 files changed, 331 insertions(+), 40 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index e861ec6..77b2132 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -118,12 +118,20 @@ const masteringChainOn = () => !!(window.currentMasteringSettings && window.curr // (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải // 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) - return trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass); + // GỠ override mastering-ON (17:00) — nút ♪ quyết định bypass (user rules). + // ƯU TIÊN track object (field midiBypass): track object phân biệt đúng + // 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) - 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), @@ -133,10 +141,18 @@ function createMasteringRoute(ctx, track, bus) { // falling back to the track object — this guarantees the A-button toggle is // 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]; + // 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 ?? track.masteringBypass); + 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 @@ -156,6 +172,23 @@ function createMasteringRoute(ctx, track, bus) { 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). function setMasteringRoute(route, bypass) { @@ -14731,9 +14764,13 @@ const App = () => { // tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/ // sessionTabs declarations (TDZ-safe). 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 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.audioBypass; + trackMasteringBypassMap[t.id] = !!t.masteringBypass; trackAudioBypassMap[t.id] = !!t.audioBypass; trackMidiBypassMap[t.id] = !!t.midiBypass; }); @@ -14896,7 +14933,7 @@ const App = () => { _n.sfDryGain.gain.value = _b ? 1 : 0; } 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.dryGain.gain.value = _ab ? 1 : 0; } @@ -16587,6 +16624,14 @@ const App = () => { 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 => ({ @@ -16605,6 +16650,9 @@ const App = () => { 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 }]); @@ -18661,6 +18709,7 @@ const App = () => { 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); @@ -18758,7 +18807,16 @@ const App = () => { 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]; + // 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 _sub_ — 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); @@ -18779,23 +18837,40 @@ const App = () => { } } 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]; - const node = activeTrackNodesRef.current[t.id]; + // 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 _sub_ — + // 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 / // sfDryGain) to skip or include the Mastering FX Chain. 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); + // ⚠️ 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; } @@ -18909,21 +18984,35 @@ const App = () => { // 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)); + ? (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; - gainNode.connect(routeGain); - gainNode.connect(dryGain); - routeGain.connect(parentNode); + // 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, 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; } return node.gainNode; @@ -18964,6 +19053,17 @@ const App = () => { 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', @@ -19084,6 +19184,15 @@ const App = () => { 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 || []; @@ -19166,6 +19275,14 @@ const App = () => { 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); + } } }); }); @@ -24603,6 +24720,12 @@ STRICT CONSTRAINTS: const node = trackNodes[trackId]; const canvas = trackVuRefs.current[key]; if (!canvas) return; + // Section tab: track nodes nằm dưới key _sub_ — + // 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 || []; @@ -24611,8 +24734,8 @@ STRICT CONSTRAINTS: var isAudible = vuTrack ? (anySoloVU ? !!vuTrack.solo : !vuTrack.muted) : true; let audioPeak = 0; - if (isAudible && node && node.analyserNode) { - const analyser = node.analyserNode; + 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++) { @@ -24620,6 +24743,24 @@ STRICT CONSTRAINTS: if (v > audioPeak) audioPeak = v; } } + // Section item trên MAIN track: VU theo sub-nodes của track này (key + // _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 diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 41ce19a..3ac06ad 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -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 // (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải // 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) -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) -return trackAudioBypassMap[track.id]!==undefined?!!trackAudioBypassMap[track.id]:!!(track.audioBypass??track.masteringBypass);};// Build the dual routing for one track: routeGain -> mastering chain (normal), +const effMidiBypass=track=>{// GỠ override mastering-ON (17:00) — nút ♪ quyết định bypass (user rules). +// ƯU TIÊN track object (field midiBypass): track object phân biệt đúng +// 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. 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 // 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 // routeGain=1) — rules hiện tại: NÚT A/♪ quyết định bypass, không phụ thuộc // 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). 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 @@ -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 // tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/ // 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 đó — // 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 @@ -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ị // 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). -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). 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 @@ -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 // AudioBuffer → audioclip mất khỏi section editor). Trước đây clips:[] // → section item mở bằng nhấp đôi không chứa audioclip. -const base=JSON.parse(JSON.stringify(t));const srcClips=(t.clips||[]).map(c=>({...c,buffer:c.buffer||null}));return{...base,clips:srcClips,sections:[],markers:[],isArmed:false,monitoringEnabled:true,instrumentId:t.instrumentId||null,instrumentProgram:t.instrumentProgram,instrumentName:t.instrumentName||null,_isSectionClone:true};}):tracks.filter(t=>t.id===trackId).map(t=>({...t,clips:[],sections:[],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 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;i0){const fadeSamples=Math.min(resultData.length,Math.floor(fx.fadeInMs/1000*sr));for(let i=0;im&&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. 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), @@ -930,13 +955,27 @@ const updateSfRouting=()=>{try{const list=activeTracksRef.current&&activeTracksR // 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 // 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 _sub_ — 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). -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 _sub_ — +// 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 / // 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.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&&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){// ⚠️ 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 // 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. @@ -957,9 +996,19 @@ window.__getTrackSfFxModule=(trackId,type)=>{const node=activeTrackNodesRef.curr // 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). // 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). -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(offsetTimet.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(offsetTime0)&&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 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) // This block is intentionally empty - audio clips already play above }// SECTION items playback -const sections=track.sections||[];if(activeTab==='main'&§ions.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'&§ions.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 activeStart=Math.max(clipStartMain,secStart);const activeEnd=Math.min(clipEndMain,secEnd);if(activeStart0){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&¬eStartMain=secStart&¬eStartMain{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 // 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. 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;ipeak)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) -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;iaudioPeak)audioPeak=v;}}// midiVuActivityRef được set bởi triggerMidiVuActivity — cả khi play +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 _sub_ — +// 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;iaudioPeak)audioPeak=v;}}// Section item trên MAIN track: VU theo sub-nodes của track này (key +// _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;iaudioPeak)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 // 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 diff --git a/app/templates/index.html b/app/templates/index.html index 4376de1..e98a6de 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -24,7 +24,7 @@ - +