From 3618e3c59139d646f8a9de5f0496d673e7bd4100 Mon Sep 17 00:00:00 2001 From: locpham Date: Sat, 8 Aug 2026 13:12:49 +0000 Subject: [PATCH] =?UTF-8?q?FIX:=20VU=20meter=20gi=E1=BB=AF=20animation=20?= =?UTF-8?q?=C4=91=C3=BAng=20tr=C6=B0=E1=BB=9Dng=20=C4=91=E1=BB=99=20=C3=A2?= =?UTF-8?q?m=20khi=20ARM=20+=20gi=E1=BB=AF=20ph=C3=ADm=20MIDI=20keyboard?= =?UTF-8?q?=20(heldMidiNotesRef,=20kh=C3=B4ng=20decay=20khi=20note=20c?= =?UTF-8?q?=C3=B2n=20gi=E1=BB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/js/app.jsx | 45 +++++++++++++++++++++++++++----- app/static/js/app.precompiled.js | 31 +++++++++++++++------- app/templates/index.html | 2 +- wiki.md | 9 +++++++ 4 files changed, 71 insertions(+), 16 deletions(-) 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 @@ - +