diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index cac86c2..5376ab4 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -14500,6 +14500,11 @@ const App = () => {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(as.trackId, scaledVel);
}
+ // Giữ VU theo trường độ: tăng counter note đang giữ
+ // (tick giữ peak cho tới khi note-off)
+ try {
+ heldMidiNotesRef.current[as.trackId] = (heldMidiNotesRef.current[as.trackId] || 0) + 1;
+ } catch (err) { }
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe);
});
}
@@ -14512,6 +14517,10 @@ const App = () => {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(at.id, scaledVel);
}
+ // Giữ VU theo trường độ: tăng counter note đang giữ
+ try {
+ heldMidiNotesRef.current[at.id] = (heldMidiNotesRef.current[at.id] || 0) + 1;
+ } catch (err) { }
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, atProg, atDest, atCh, atSe);
});
}
@@ -14535,6 +14544,16 @@ const App = () => {
if (!st.synth_engine && st.midiChannel === undefined) return;
var stCh = assignTrackMidiChannel(st, stopTracks);
window.SonicSF.stopNote(stCh, pitch);
+ // Giảm counter note đang giữ — hết note → VU được phép decay/tắt
+ // (tick sẽ thấy counter = 0 và không còn giữ peak nữa)
+ try {
+ if (heldMidiNotesRef.current[st.id] !== undefined) {
+ heldMidiNotesRef.current[st.id] = Math.max(0, heldMidiNotesRef.current[st.id] - 1);
+ if (heldMidiNotesRef.current[st.id] === 0) {
+ delete heldMidiNotesRef.current[st.id];
+ }
+ }
+ } catch (err) { }
});
}
}
@@ -15494,6 +15513,11 @@ const App = () => {
}, [isPlaying]);
const midiVuActivityRef = useRef({});
+ // Đếm số note MIDI đang GIỮ per-track (ARM + MIDI keyboard live input).
+ // VU tick dùng ref này: còn note giữ → giữ peak (không decay) → VU animate
+ // ĐÚNG trường độ âm thanh (user bug: nhấn phím giữ âm còn kêu nhưng VU tắt
+ // sau ~0.5s vì decay 0.75/frame). Hết note (note-off) → mới decay/tắt.
+ const heldMidiNotesRef = useRef({});
const triggerMidiVuActivity = (trackId, velocity) => {
if (!trackId) return;
const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
@@ -20595,6 +20619,9 @@ const App = () => {
// âm cũ — user 06:45: tắt âm MAIN items khi vào SECTION-TAB → VU main
// items phải tắt theo, không "diễn" tiếp).
try { midiVuActivityRef.current = {}; } catch (e) { }
+ // Clear luôn counter note đang giữ — nếu không, sau stop (âm đã dừng)
+ // tick vẫn thấy heldCnt > 0 → giữ peak → VU dính mãi (user bug 08:08).
+ try { heldMidiNotesRef.current = {}; } catch (e) { }
stopMidiCapture();
if (window.SonicSF) {
try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
@@ -25856,12 +25883,18 @@ STRICT CONSTRAINTS:
// velocity thấp (âm SF nhỏ < 0.03) → VU không nhảy (user bug 07:10).
let midiPeak = isAudible ? (midiVuActivityRef.current[vuKey] || 0) : 0;
if (midiPeak > 0) {
- // Decay 0.75 (~0.5s) — cân bằng: note đơn/velocity thấp hiển thị rõ
- // NHƯNG tắt nhanh sau hết note (decay 0.85 ~1s quá lâu — user:
- // "vẫn diễn animation" sau khi âm hết — bug 07:15).
- midiVuActivityRef.current[vuKey] = midiPeak * 0.75;
- if (midiVuActivityRef.current[vuKey] < 0.01) {
- midiVuActivityRef.current[vuKey] = 0;
+ // ARM + MIDI keyboard: còn note đang GIỮ → giữ nguyên peak, KHÔNG
+ // decay → VU animate đúng trường độ âm thanh (user bug 08:08: âm còn
+ // kêu nhưng VU tắt sau ~0.5s vì decay 0.75/frame). Hết note → decay
+ // 0.75 (~0.5s) tắt nhanh như trước.
+ const heldCnt = heldMidiNotesRef.current[vuKey] || 0;
+ if (heldCnt > 0) {
+ // Giữ nguyên peak trong khi âm đang kêu (không decay)
+ } else {
+ midiVuActivityRef.current[vuKey] = midiPeak * 0.75;
+ if (midiVuActivityRef.current[vuKey] < 0.01) {
+ midiVuActivityRef.current[vuKey] = 0;
+ }
}
}
// ⚠️ KHÔNG gộp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mọi
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index e003956..764b216 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -689,13 +689,18 @@ const selId=selectedMidiInputIdRef.current;if(selId&&selId!=='ALL'&&input.id!==s
const scaledVel=Math.min(127,Math.max(1,Math.round(rawVel)));if(cmd===0x9&&rawVel>0){lastMidiNoteRef.current={pitch,velocity:scaledVel,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity:scaledVel,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// Route MIDI input to ALL armed tracks on their dedicated channels
// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F)
if(window.SonicSF){var allTracks=activeTracksRef.current||[];var arSubs=subTabsRef&&subTabsRef.current?subTabsRef.current.filter(function(s){return s.type==='PIANO_ROLL'&&s.isArmed;}):[];var armedTracks=allTracks.filter(function(t){return t.isArmed;});// Piano Roll arming has priority: route to each armed sub-tab's parent track
-if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});var asCh=asTrk?assignTrackMidiChannel(asTrk,allTracks):0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(as.trackId,scaledVel);}window.SonicSF.playNote(pitch,scaledVel,60000,undefined,asProg,null,asCh,asSe);});}// Route to ALL armed tracks (not just the first one)
-armedTracks.forEach(function(at){var atCh=assignTrackMidiChannel(at,allTracks);var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(at.id,scaledVel);}window.SonicSF.playNote(pitch,scaledVel,60000,undefined,atProg,atDest,atCh,atSe);});}}else if(cmd===0x8||cmd===0x9&&rawVel===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note on ALL tracks (not just armed) to prevent stuck notes
+if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});var asCh=asTrk?assignTrackMidiChannel(asTrk,allTracks):0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(as.trackId,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ
+// (tick giữ peak cho tới khi note-off)
+try{heldMidiNotesRef.current[as.trackId]=(heldMidiNotesRef.current[as.trackId]||0)+1;}catch(err){}window.SonicSF.playNote(pitch,scaledVel,60000,undefined,asProg,null,asCh,asSe);});}// Route to ALL armed tracks (not just the first one)
+armedTracks.forEach(function(at){var atCh=assignTrackMidiChannel(at,allTracks);var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;if(window.triggerMidiVuActivity){window.triggerMidiVuActivity(at.id,scaledVel);}// Giữ VU theo trường độ: tăng counter note đang giữ
+try{heldMidiNotesRef.current[at.id]=(heldMidiNotesRef.current[at.id]||0)+1;}catch(err){}window.SonicSF.playNote(pitch,scaledVel,60000,undefined,atProg,atDest,atCh,atSe);});}}else if(cmd===0x8||cmd===0x9&&rawVel===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note on ALL tracks (not just armed) to prevent stuck notes
// when ARM is toggled off while a key is held
if(window.SonicSF&&window.SonicSF.stopNote){var stopTracks=activeTracksRef.current||[];stopTracks.forEach(function(st){// Only stop channels that actually carry this track's notes —
// an index-based fallback could hit another track's dedicated
// channel and kill its sound.
-if(!st.synth_engine&&st.midiChannel===undefined)return;var stCh=assignTrackMidiChannel(st,stopTracks);window.SonicSF.stopNote(stCh,pitch);});}}// ── Sustain (CC64), Modulation (CC1), Pitch Bend ──
+if(!st.synth_engine&&st.midiChannel===undefined)return;var stCh=assignTrackMidiChannel(st,stopTracks);window.SonicSF.stopNote(stCh,pitch);// Giảm counter note đang giữ — hết note → VU được phép decay/tắt
+// (tick sẽ thấy counter = 0 và không còn giữ peak nữa)
+try{if(heldMidiNotesRef.current[st.id]!==undefined){heldMidiNotesRef.current[st.id]=Math.max(0,heldMidiNotesRef.current[st.id]-1);if(heldMidiNotesRef.current[st.id]===0){delete heldMidiNotesRef.current[st.id];}}}catch(err){}});}}// ── Sustain (CC64), Modulation (CC1), Pitch Bend ──
const midiCh=msg.data[0]&0x0F;if(cmd===0xB){// Controller Change: forward to ALL armed tracks' dedicated channels
const cc=msg.data[1];const val=msg.data[2];if(window.SonicSF&&window.SonicSF.controllerChange){var ccTracks=activeTracksRef.current||[];var hasArmed=ccTracks.some(function(t){return t.isArmed;});if(hasArmed){ccTracks.forEach(function(ct){if(!ct.isArmed)return;var ctCh=assignTrackMidiChannel(ct,ccTracks);window.SonicSF.controllerChange(ctCh,cc,val);});}else{window.SonicSF.controllerChange(midiCh,cc,val);}}}else if(cmd===0xE){// Pitch Bend: forward to ALL armed tracks' dedicated channels
const lsb=msg.data[1];const msb=msg.data[2];const bendVal=msb<<7|lsb;if(window.SonicSF&&window.SonicSF.pitchBend){var pbTracks=activeTracksRef.current||[];var hasArmedPB=pbTracks.some(function(t){return t.isArmed;});if(hasArmedPB){pbTracks.forEach(function(pt){if(!pt.isArmed)return;var ptCh=assignTrackMidiChannel(pt,pbTracks);window.SonicSF.pitchBend(ptCh,bendVal);});}else{window.SonicSF.pitchBend(midiCh,bendVal);}}}// Forward to active MIDI recorders
@@ -803,7 +808,11 @@ mainResumeRef.current=null;}}},[activeTab]);const[selectionCleared,setSelectionC
// Detect MAIN/SECTION play vừa DỪNG (hết notes/projectEnd/stop): nếu đang ở
// PIANO ROLL tab (không play tab) → playhead về 0 — user: main play hết →
// không còn midi note → piano roll phải về đầu (không giữ local cuối).
-const prevIsPlayingRef=useRef(false);useEffect(()=>{const wasPlaying=prevIsPlayingRef.current;prevIsPlayingRef.current=isPlaying;if(wasPlaying&&!isPlaying){const _tabId=activeTabRef.current;if(_tabId&&_tabId.startsWith('midi_')){const _prSt=subTabsRef.current.find(s=>s.id===_tabId);if(_prSt&&!_prSt.isPlaying){setSubTabs(prev=>prev.map(s=>s.id===_tabId?{...s,currentTime:0}:s));}}}},[isPlaying]);const midiVuActivityRef=useRef({});const triggerMidiVuActivity=(trackId,velocity)=>{if(!trackId)return;const velFactor=typeof velocity==='number'?velocity>1?velocity/127:velocity:0.8;const peak=Math.min(1.0,Math.max(0.15,velFactor));midiVuActivityRef.current[trackId]=peak;// Log nguồn trigger (rate-limited 1/20 fire): xác định ai còn fire VU khi
+const prevIsPlayingRef=useRef(false);useEffect(()=>{const wasPlaying=prevIsPlayingRef.current;prevIsPlayingRef.current=isPlaying;if(wasPlaying&&!isPlaying){const _tabId=activeTabRef.current;if(_tabId&&_tabId.startsWith('midi_')){const _prSt=subTabsRef.current.find(s=>s.id===_tabId);if(_prSt&&!_prSt.isPlaying){setSubTabs(prev=>prev.map(s=>s.id===_tabId?{...s,currentTime:0}:s));}}}},[isPlaying]);const midiVuActivityRef=useRef({});// Đếm số note MIDI đang GIỮ per-track (ARM + MIDI keyboard live input).
+// VU tick dùng ref này: còn note giữ → giữ peak (không decay) → VU animate
+// ĐÚNG trường độ âm thanh (user bug: nhấn phím giữ âm còn kêu nhưng VU tắt
+// sau ~0.5s vì decay 0.75/frame). Hết note (note-off) → mới decay/tắt.
+const heldMidiNotesRef=useRef({});const triggerMidiVuActivity=(trackId,velocity)=>{if(!trackId)return;const velFactor=typeof velocity==='number'?velocity>1?velocity/127:velocity:0.8;const peak=Math.min(1.0,Math.max(0.15,velFactor));midiVuActivityRef.current[trackId]=peak;// Log nguồn trigger (rate-limited 1/20 fire): xác định ai còn fire VU khi
// âm đã ngừng (user: VU vẫn animation sau khi hết âm).
try{const _tf=window.__trigFrame=(window.__trigFrame||0)+1;if(_tf%20===1){console.log('[VUTrig]',trackId,'vel=',velocity,'peak=',peak.toFixed(2),'play=',isPlaying,'tab=',activeTabRef.current);}}catch(e){}};window.triggerMidiVuActivity=triggerMidiVuActivity;// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
const[tempTabActive,setTempTabActive]=useState(false);const[tempTabBuffer,setTempTabBuffer]=useState(null);const[tempTabTrackId,setTempTabTrackId]=useState(null);const[tempTabOrigStart,setTempTabOrigStart]=useState(0);const[tempTabOrigEnd,setTempTabOrigEnd]=useState(0);const tempTabCanvasRef=useRef(null);// Effect parameters for temp tab
@@ -1273,7 +1282,9 @@ if(isPlaying){stopAllPlayback();setSubTabs(prev=>prev.map(s=>s.isPlaying?{...s,i
animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}catch(e){console.error('[Play] Piano Roll play error:',e);showToast('Lỗi phát Piano Roll: '+e.message,'error');}}return;}const context=getAudioContext();if(isPlaying){stopAllPlayback();}else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);}};handlePlayPauseRef.current=handlePlayPause;const handlePianoRollRealtimePlay=trackIds=>{const prSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!prSt)return;var prLayers=[];var ghostData=window.SonicGhost&&window.SonicGhost.extractGhostLayers?window.SonicGhost.extractGhostLayers(activeTracks,prSt.trackId,prSt.target_id,parseInt(bpm)||120):[];(ghostData||[]).forEach(function(layer){if(!trackIds||trackIds.indexOf(layer.track_id)===-1)return;var ltrk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});prLayers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:ltrk?ltrk.instrumentProgram:undefined,instrumentName:ltrk?ltrk.instrumentName:undefined,synthEngine:ltrk?ltrk.synth_engine:undefined});});stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback();};const stopAllPlayback=()=>{try{activeSourcesRef.current.forEach(src=>{try{src.stop();}catch(e){}});activeSourcesRef.current=[];Object.values(activeTrackNodesRef.current).forEach(n=>{if(n.fxStopFn){try{n.fxStopFn();}catch(e){}}});activeTrackNodesRef.current={};// Clear VU activity NGAY: hết âm → VU tắt tức thì (không decay 0.35s từ
// âm cũ — user 06:45: tắt âm MAIN items khi vào SECTION-TAB → VU main
// items phải tắt theo, không "diễn" tiếp).
-try{midiVuActivityRef.current={};}catch(e){}stopMidiCapture();if(window.SonicSF){try{window.SonicSF.stopAll();}catch(e){console.warn('[Stop] stopAll error:',e);}// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
+try{midiVuActivityRef.current={};}catch(e){}// Clear luôn counter note đang giữ — nếu không, sau stop (âm đã dừng)
+// tick vẫn thấy heldCnt > 0 → giữ peak → VU dính mãi (user bug 08:08).
+try{heldMidiNotesRef.current={};}catch(e){}stopMidiCapture();if(window.SonicSF){try{window.SonicSF.stopAll();}catch(e){console.warn('[Stop] stopAll error:',e);}// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
if(window.SonicSF.panic){try{window.SonicSF.panic();}catch(e){console.warn('[Stop] panic error:',e);}}}}catch(e){console.warn('[Stop] stopAllPlayback error:',e);}setIsPlaying(false);setSubTabs(prev=>prev.map(s=>({...s,isPlaying:false})));updateSfRouting();};const seekPlaybackTo=time=>{const isSubTab=subTabs.some(sub=>sub.id===activeTab);if(isSubTab){const st=subTabs.find(s=>s.id===activeTab);if(!st)return;if(st.isPlaying){stopAllPlayback();window.SonicSF.stopAll();setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time,isPlaying:true}:s));const ctx=getAudioContext();startOffsetTimeRef.current=time;startAudioTimeRef.current=ctx.currentTime;startBufferOffsetRef.current=time*(st.speed||1.0);if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,time);}startSubTabPlayback(st,time);}else{setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:time}:s));}}else{if(isPlaying){stopAllPlayback();setCurrentTime(time);startOffsetTimeRef.current=time;startAudioTimeRef.current=getAudioContext().currentTime;startTrackPlayback(time);setIsPlaying(true);}else{setCurrentTime(time);}}};const handleStop=()=>{if(recordingStateRef.current==='RECORDING'||recordingStateRef.current==='COUNT_IN'){stopRecordingTake();return;}stopAllPlayback();if(activeTab!=='main'&&!activeTab.startsWith('session_')){setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,currentTime:0}:s));}else{setCurrentTime(0);}};const drawVuMeter=(canvas,db)=>{if(!canvas)return;const ctx=canvas.getContext('2d');if(!ctx)return;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);const minDb=-60;const maxDb=0;const frac=Math.max(0,Math.min(1,(db-minDb)/(maxDb-minDb)));ctx.fillStyle='#18181b';ctx.fillRect(0,0,w,h);const grad=ctx.createLinearGradient(0,0,w,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#eab308');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(0,0,w*frac,h);if(db>=-0.5){ctx.fillStyle='#ff0000';ctx.fillRect(w-6,0,6,h);}};const handleRecordClick=async()=>{if(recordingState==='RECORDING'||recordingState==='COUNT_IN'){await stopRecordingTake();return;}// Check if piano roll tab is active and armed
const activePianoRoll=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL'&&s.isArmed);if(activePianoRoll&&selectedMidiInputId){setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1... (MIDI Piano Roll)','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startPianoRollRecording(activePianoRoll);},countInDuration*1000);return;}const armed=activeTracks.filter(t=>t.isArmed&&t.inputSource?.deviceType&&t.inputSource.deviceType!=='NONE');if(armed.length===0){showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.','warning');return;}setRecordingState('COUNT_IN');const secondsPerBeat=60.0/(parseInt(bpm)||120);const countInDuration=secondsPerBeat*4;const audioCtx=getAudioContext();const now=audioCtx.currentTime;showToast('Metronome Count-in: 4... 3... 2... 1...','info');for(let i=0;i<4;i++){playMetronomeClick(now+i*secondsPerBeat,i===0);}setTimeout(()=>{startRecordingTake(armed);},countInDuration*1000);};const startPianoRollRecording=tab=>{try{const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const startTime=currentTime;const startBeat=startTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);midiRec.tempTabId=tab.id;midiRec.selectedMidiInputId=selectedMidiInputId||'ALL';midiRec.start(startTime/secondsPerBeat,midiRec.selectedMidiInputId);pianoRollRecorderRef.current=midiRec;activeMIDIRecordersRef.current['piano_roll']=midiRec;setRecStartTimelineTime(startTime);recordingStartTimeRef.current=startTime;setRecordingState('RECORDING');setRecTempMidiNotes([]);const ctx=getAudioContext();const silentBuf=ctx.createBuffer(1,128,ctx.sampleRate);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,buffer:silentBuf,isPlaying:true}:s));startSubTabPlayback({...tab,buffer:silentBuf},startTime);startOffsetTimeRef.current=startTime;startAudioTimeRef.current=context.currentTime;setIsPlaying(true);midiRec.onNoteOn=(pitch,currentBeat)=>{const elapsedBeats=Math.max(0,currentBeat);const sec=elapsedBeats*(60.0/(parseInt(bpm)||120));const activeNotes=Array.from(midiRec.activeNotes.values()).map(n=>({id:'rec_'+n.pitch+'_'+currentBeat,pitch:n.pitch,start_beat:n.start_beat,duration_beats:Math.max(0.125,currentBeat-n.start_beat),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const rec=midiRec.recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:n.start_beat,duration_beats:n.duration_beats,velocity:Math.min(1,n.velocity||0.8),pan:0.0}));const allNotes=[...rec,...activeNotes];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);setSubTabs(prev=>prev.map(s=>s.id===tab.id?{...s,currentTime:startTime+sec,isDirty:true}:s));};if(!tab._recordingStarted){tab._recordingStarted=true;}showToast('Recording MIDI to Piano Roll...','info');}catch(err){console.error('startPianoRollRecording error:',err);showToast('Lỗi khi bắt đầu ghi âm Piano Roll: '+err.message,'warning');setRecordingState('IDLE');}};handleRecordClickRef.current=handleRecordClick;const startRecordingTake=async armedTracks=>{const context=getAudioContext();if(context.state==='suspended'){await context.resume();}setRecordingState('RECORDING');setRecTempMidiNotes([]);setRecTempAudioBuffer(null);const startTimelineTime=currentTime;setRecStartTimelineTime(startTimelineTime);recordingStartTimeRef.current=startTimelineTime;const secondsPerBeat=60.0/(parseInt(bpm)||120);const startBeat=startTimelineTime/secondsPerBeat;nextMetronomeBeatRef.current=Math.ceil(startBeat);activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};startOffsetTimeRef.current=startTimelineTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(startTimelineTime);setIsPlaying(true);let midiRecList=[];for(let track of armedTracks){if(track.inputSource.deviceType==='MIDI_KEYBOARD'){const midiRec=new ClientMIDIRecorder(context,parseInt(bpm)||120,4);const tempMidiItemId='midi_rec_'+Date.now()+'_'+track.id;midiRec.tempMidiItemId=tempMidiItemId;updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:[...(t.midiItems||[]),{id:tempMidiItemId,name:'Recording...',startTime:startTimelineTime,duration:4*(secondsPerBeat*4),notes:[]}]};}));midiRec.onNoteOn=(pitch,currentBeat)=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,0);setTimeout(()=>drawVuMeter(canvas,-60),100);}const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.onNoteOff=()=>{const currentBeat=(context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec)/(60.0/midiRec.bpm);const elapsedBeats=Math.max(0,currentBeat);const elapsedSec=elapsedBeats*secondsPerBeat;const durationSec=Math.max(4*(secondsPerBeat*4),elapsedSec);const activeNotesArray=Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}));const allNotes=[...midiRec.recordedNotes,...activeNotesArray];setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);if(midiRec.tempMidiItemId){updateActiveTracks(prev=>prev.map(t=>{if(t.id!==track.id)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}};midiRec.start(startTimelineTime/(secondsPerBeat*4),track.inputSource.deviceId);activeMIDIRecordersRef.current[track.id]=midiRec;midiRecList.push({trackId:track.id,midiRec});}else if(track.inputSource.deviceType==='MICROPHONE'){const audioRec=new ClientAudioRecorder(context);try{await audioRec.initializeInput(track.inputSource.deviceId);recordingPCMDataRef.current[track.id]=[];audioRec.onLevelUpdate=db=>{const canvas=trackVuRefs.current[track.id];if(canvas){drawVuMeter(canvas,db);}};audioRec.onPCMChunk=chunk=>{if(recordingPCMDataRef.current[track.id]){const currentData=recordingPCMDataRef.current[track.id];const newData=new Float32Array(currentData.length+chunk.length);newData.set(currentData);newData.set(chunk,currentData.length);recordingPCMDataRef.current[track.id]=newData;}};const monitorGain=track.monitoringEnabled?masterBus?masterBus.input:context.destination:null;await audioRec.start(monitorGain,track.monitoringEnabled);activeAudioRecordersRef.current[track.id]=audioRec;}catch(err){console.error('Failed to initialize microphone:',err);showToast('Không khởi động được micro: '+err.message,'warning');}}}recordingSyncRef.current=setInterval(()=>{const secondsPerBeatInt=60.0/(parseInt(bpm)||120);for(let{trackId,midiRec}of midiRecList){if(!midiRec.isRecording||!midiRec.tempMidiItemId)continue;const currentTimeSec=Math.max(0,getAudioContext().currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const currentBeat=currentTimeSec/secondsPerBeatInt;const allNotes=[...midiRec.recordedNotes,...Array.from(midiRec.activeNotes.values()).map(n=>({...n,duration_beats:currentBeat-n.start_beat}))];const durationSec=Math.max(4*(secondsPerBeatInt*4),currentTimeSec);if(Math.random()<0.2){// Throttle log to prevent flooding (approx 2 logs/sec)
console.log(`[DevLog] [MIDI Rec Sync] Temp Item ID: ${midiRec.tempMidiItemId}, Duration: ${durationSec.toFixed(2)}s, ActiveNotes: ${midiRec.activeNotes.size}, RecordedNotes: ${midiRec.recordedNotes.length}`);}setRecTempMidiNotes(allNotes);setCanvasRedrawCount(n=>n+1);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:t.midiItems.map(m=>m.id===midiRec.tempMidiItemId?{...m,notes:allNotes,duration:durationSec}:m)};}));}},100);showToast('Đang ghi âm...','info');};const stopRecordingTake=async()=>{setRecordingState('IDLE');stopAllPlayback();const context=getAudioContext();const secondsPerBeat=60.0/(parseInt(bpm)||120);const secondsPerBar=secondsPerBeat*4;const midiRecorders=activeMIDIRecordersRef.current;const audioRecorders=activeAudioRecordersRef.current;Object.keys(trackVuRefs.current).forEach(tid=>{const canvas=trackVuRefs.current[tid];if(canvas)drawVuMeter(canvas,-60);});let hasRecordedAnything=false;for(let trackId in midiRecorders){const midiRec=midiRecorders[trackId];const recordedNotes=midiRec.stop();if(midiRec.tempTabId){if(recordedNotes.length>0){const newNotes=recordedNotes.map((n,i)=>({id:'rec_'+Date.now()+'_'+i,pitch:n.pitch,start_beat:Math.max(0,n.start_beat||0),duration_beats:Math.max(0.125,n.duration_beats||0.25),velocity:Math.min(1,n.velocity||0.8),pan:0.0}));setSubTabs(prev=>prev.map(s=>s.id===midiRec.tempTabId?{...s,notes:[...(s.notes||[]),...newNotes],isDirty:true}:s));setCanvasRedrawCount(n=>n+1);showToast(`Đã ghi ${recordedNotes.length} notes vào Piano Roll.`,'success');}hasRecordedAnything=true;}else if(midiRec.tempMidiItemId){const recCurrentTimeSec=Math.max(0,context.currentTime-midiRec.recStartAudioTime-midiRec.latencyCompSec);const recElapsedBeats=recCurrentTimeSec/(60.0/midiRec.bpm);const totalDurationBeats=Math.max(4.0,recordedNotes.length>0?Math.max(recElapsedBeats,...recordedNotes.map(n=>n.start_beat+n.duration_beats)):recElapsedBeats);updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const itemIndex=(t.midiItems||[]).findIndex(m=>m.id===midiRec.tempMidiItemId);if(itemIndex>=0){const updatedItems=[...t.midiItems];updatedItems[itemIndex]={...updatedItems[itemIndex],name:recordedNotes.length>0?'Recorded MIDI':'Empty MIDI',notes:recordedNotes,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar};return{...t,midiItems:updatedItems};}const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:t.id,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));if(recordedNotes.length>0){hasRecordedAnything=true;}}else if(recordedNotes.length>0){hasRecordedAnything=true;const totalDurationBeats=Math.max(4.0,...recordedNotes.map(n=>n.start_beat+n.duration_beats));const newMidiItem={id:'midi_rec_'+Date.now(),name:'Recorded MIDI',parent_track_id:trackId,startTime:recordingStartTimeRef.current,duration:Math.ceil(totalDurationBeats/4)*secondsPerBar,length_bars:Math.ceil(totalDurationBeats/4),notes:recordedNotes};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),newMidiItem]};}));}}for(let trackId in audioRecorders){const audioRec=audioRecorders[trackId];const audioBuffer=await audioRec.stop();if(audioBuffer&&audioBuffer.duration>0.05){hasRecordedAnything=true;const newClip={id:'clip_rec_'+Date.now(),name:'Recorded Audio.wav',buffer:audioBuffer,startTime:recordingStartTimeRef.current,speed:1.0};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=[...(t.clips||[]),newClip];return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));}}if(recordingSyncRef.current){clearInterval(recordingSyncRef.current);recordingSyncRef.current=null;}activeMIDIRecordersRef.current={};activeAudioRecordersRef.current={};recordingPCMDataRef.current={};setRecTempMidiNotes([]);setRecTempAudioBuffer(null);if(hasRecordedAnything){showToast('Đã thu và lưu bản ghi vào timeline.','success');}else{showToast('Đã dừng ghi âm (không phát hiện tín hiệu đầu vào).','info');}};const handleSubTabResizeMouseDown=e=>{e.preventDefault();e.stopPropagation();const startY=e.clientY;const startHeight=subTabHeight;const handleMouseMove=moveEvent=>{const deltaY=moveEvent.clientY-startY;const newHeight=Math.max(48,Math.min(400,startHeight+deltaY));setSubTabHeight(newHeight);};const handleMouseUp=()=>{document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};// ── Playhead set with seek+play ──
@@ -1520,10 +1531,12 @@ if(audioPeak<=0.001){for(var sk in trackNodes){if(sk.indexOf(trackId+'_sub_')===
// note (nhánh delayed: setTimeout khớp thời điểm phát; nhánh instant:
// note đang kêu). KHÔNG gate theo amplitude SF — gate chặn note đơn /
// velocity thấp (âm SF nhỏ < 0.03) → VU không nhảy (user bug 07:10).
-let midiPeak=isAudible?midiVuActivityRef.current[vuKey]||0:0;if(midiPeak>0){// Decay 0.75 (~0.5s) — cân bằng: note đơn/velocity thấp hiển thị rõ
-// NHƯNG tắt nhanh sau hết note (decay 0.85 ~1s quá lâu — user:
-// "vẫn diễn animation" sau khi âm hết — bug 07:15).
-midiVuActivityRef.current[vuKey]=midiPeak*0.75;if(midiVuActivityRef.current[vuKey]<0.01){midiVuActivityRef.current[vuKey]=0;}}// ⚠️ KHÔNG gộp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mọi
+let midiPeak=isAudible?midiVuActivityRef.current[vuKey]||0:0;if(midiPeak>0){// ARM + MIDI keyboard: còn note đang GIỮ → giữ nguyên peak, KHÔNG
+// decay → VU animate đúng trường độ âm thanh (user bug 08:08: âm còn
+// kêu nhưng VU tắt sau ~0.5s vì decay 0.75/frame). Hết note → decay
+// 0.75 (~0.5s) tắt nhanh như trước.
+const heldCnt=heldMidiNotesRef.current[vuKey]||0;if(heldCnt>0){// Giữ nguyên peak trong khi âm đang kêu (không decay)
+}else{midiVuActivityRef.current[vuKey]=midiPeak*0.75;if(midiVuActivityRef.current[vuKey]<0.01){midiVuActivityRef.current[vuKey]=0;}}}// ⚠️ KHÔNG gộp sfAudioPeak vào audioPeak: SF là 1 output CHUNG cho mọi
// track MIDI → gộp làm track VU nhảy CÙNG NHAU (user bug 07:00).
// audioPeak chỉ theo âm TRACK thật (vNode/sub-node analyser).
// Deadband: bỏ noise nền analyser (log 07:15 — vNode noise dao động
diff --git a/app/templates/index.html b/app/templates/index.html
index c4eb5b3..0e3136b 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -43,7 +43,7 @@
-
+