fix: TempoTrackLane bar spacing drift with floating-point BPM

- Replace t += beatDuration loop (floating accumulation drifts)
- Iterate by integer beat number: t = bn * beatDuration
- Each bar position computed independently, no cumulative error
- Same fix for snapInterval loop
- Fixes unequal bar widths for BPM like 77, 81
This commit is contained in:
2026-07-27 08:25:47 +07:00
parent 6a409b954d
commit f59cc304d4
2 changed files with 11 additions and 7 deletions
+9 -5
View File
@@ -1325,9 +1325,11 @@ const TempoTrackLane = ({
const PADDING_LEFT = 0;
const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + CLIP_BUFFER / zoom;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
const firstBeatNum = Math.floor(tStart / beatDuration);
const lastBeatNum = Math.ceil(tEnd / beatDuration);
for (let bn = firstBeatNum; bn <= lastBeatNum; bn++) {
const t = bn * beatDuration;
const beatNum = bn + 1;
const isBar = beatNum % 4 === 1;
const localX = (t - tStart) * zoom;
if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue;
@@ -1365,8 +1367,10 @@ const TempoTrackLane = ({
if (snapValue === '4') divisor = 4; else if (snapValue === '1') divisor = 1; else if (snapValue === '1/2') divisor = 0.5; else if (snapValue === '1/4') divisor = 0.25; else if (snapValue === '1/8') divisor = 0.125; else if (snapValue === '1/16') divisor = 0.0625; else if (snapValue === '1/32') divisor = 0.03125;
const snapInterval = beatDuration * divisor;
if (snapInterval * zoom >= 4) {
const firstSnap = Math.floor(tStart / snapInterval) * snapInterval;
for (let t = firstSnap; t <= tEnd; t += snapInterval) {
const firstSnapNum = Math.floor(tStart / snapInterval);
const lastSnapNum = Math.ceil(tEnd / snapInterval);
for (let sn = firstSnapNum; sn <= lastSnapNum; sn++) {
const t = sn * snapInterval;
const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001;
if (!onBeat) {
const localX = (t - tStart) * zoom;
+2 -2
View File
@@ -59,8 +59,8 @@ const dblSecItems=track.sections||[];let dblSecHit=null;for(const sec of dblSecI
const dblMidiItems=track.midiItems||[];let dblMidiHit=null;for(const midi of dblMidiItems){if(time>=midi.startTime&&time<midi.startTime+midi.duration){dblMidiHit=midi;break;}}if(dblMidiHit){e.preventDefault();e.stopPropagation();if(onEditMidiInTab)onEditMidiInTab(track.id,dblMidiHit.id);return;}const clickedClip=clips.find(c=>time>=c.startTime&&time<c.startTime+c.buffer.duration/(c.speed||1.0));if(clickedClip){e.preventDefault();e.stopPropagation();if(onEditClipInSubTab){onEditClipInSubTab(track.id,clickedClip.id);}}},onContextMenu:e=>{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);// Detect section under cursor
const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time<sec.start+sec.duration){hitSectionId=sec.id;break;}}if(onContextMenu)onContextMenu(e,track.id,time,hitSectionId);}}));};const TimelineRuler=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount})=>{const canvasRef=useRef(null);const RULER_HEIGHT=40;const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;const height=RULER_HEIGHT;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#242424';ctx.fillRect(0,0,drawWidth,height);ctx.strokeStyle='rgba(255,255,255,0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,height-0.5);ctx.lineTo(drawWidth,height-0.5);ctx.stroke();const CLIP_BUFFER=400;const PADDING_LEFT=0;const tStart=scrollLeftVal/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth)/zoom+CLIP_BUFFER/zoom;// Draw time duration labels with drag-selection markers
const minTimePx=60;const rawSecInt=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInt)||120;if(secInterval*zoom<minTimePx)secInterval=Math.ceil(minTimePx/zoom);const firstSec=Math.floor(tStart/secInterval)*secInterval;for(let t=firstSec;t<=tEnd;t+=secInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.12)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.strokeStyle='rgba(255, 180, 100, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,10);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.7)';ctx.font='bold 11px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,24);}},[bpm,zoom,timelineWidth,viewportWidth,scrollLeft,canvasRedrawCount]);return React.createElement(React.Fragment,null,React.createElement("div",{key:"virtual-spacer-ruler",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown)onRulerMouseDown(e);else onPlayheadSet(time,e.shiftKey);}}));};const TempoTrackLane=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount,leadInMargin:propLeadIn})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;// Use scrollLeft prop directly (DOM traversal broken by sticky wrapper)
let scrollLeftVal=scrollLeft||0;const height=40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const leadIn=propLeadIn!==undefined?propLeadIn:0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+3,11);}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='7px Inter, sans-serif';const bar=Math.floor((beatNum-1)/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,height-3);}// Draw snap sub-ticks at the bottom
if(snapValue&&snapValue!=='free'){ctx.strokeStyle='rgba(255, 255, 255, 0.15)';ctx.lineWidth=0.8;let divisor=1;if(snapValue==='4')divisor=4;else if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const snapInterval=beatDuration*divisor;if(snapInterval*zoom>=4){const firstSnap=Math.floor(tStart/snapInterval)*snapInterval;for(let t=firstSnap;t<=tEnd;t+=snapInterval){const onBeat=Math.abs(t/beatDuration-Math.round(t/beatDuration))<0.001;if(!onBeat){const localX=(t-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,height-6);ctx.lineTo(localX,height);ctx.stroke();}}}}// Draw time duration labels (restored from original time ruler)
let scrollLeftVal=scrollLeft||0;const height=40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const leadIn=propLeadIn!==undefined?propLeadIn:0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;const firstBeatNum=Math.floor(tStart/beatDuration);const lastBeatNum=Math.ceil(tEnd/beatDuration);for(let bn=firstBeatNum;bn<=lastBeatNum;bn++){const t=bn*beatDuration;const beatNum=bn+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+3,11);}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='7px Inter, sans-serif';const bar=Math.floor((beatNum-1)/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,height-3);}// Draw snap sub-ticks at the bottom
if(snapValue&&snapValue!=='free'){ctx.strokeStyle='rgba(255, 255, 255, 0.15)';ctx.lineWidth=0.8;let divisor=1;if(snapValue==='4')divisor=4;else if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const snapInterval=beatDuration*divisor;if(snapInterval*zoom>=4){const firstSnapNum=Math.floor(tStart/snapInterval);const lastSnapNum=Math.ceil(tEnd/snapInterval);for(let sn=firstSnapNum;sn<=lastSnapNum;sn++){const t=sn*snapInterval;const onBeat=Math.abs(t/beatDuration-Math.round(t/beatDuration))<0.001;if(!onBeat){const localX=(t-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,height-6);ctx.lineTo(localX,height);ctx.stroke();}}}}// Draw time duration labels (restored from original time ruler)
const minTimePx=60;const rawSecInterval=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInterval)||120;if(secInterval*zoom<minTimePx)secInterval=Math.ceil(minTimePx/zoom);const firstSec=Math.floor(tStart/secInterval)*secInterval;for(let t=firstSec;t<=tEnd;t+=secInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.15)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,16);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.5)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,12);}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='right';ctx.fillText(`${bpm} BPM`,drawWidth-6,12);},[bpm,zoom,timelineWidth,viewportWidth,snapValue,scrollLeft,canvasRedrawCount,propLeadIn]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer-tempo",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown){onRulerMouseDown(e);}else{onPlayheadSet(time,e.shiftKey);}}}));};// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform=({buffer,subTabId,activeTab,currentTime,selectionStart,selectionEnd,onSelectRange,onPlayheadSet,onContextMenu,activeTool,zoom,timelineWidth,color,name,speed=1.0,onSpeedChange,volumeNodes=[],panningNodes=[],fadeInLen=0,fadeOutLen=0,graphMode=null,onUpdateNodes,onUpdateFade,onModeToggle,selectedNodeTime,setSelectedNodeTime,channelInfo=null})=>{const canvasRef=useRef(null);const isStretchingRef=useRef(false);const stretchStartRef=useRef({mouseX:0,originalDuration:0,originalSpeed:1.0});const subTabAnchorRef=useRef(null);const isStereo=channelInfo?channelInfo.isStereo:buffer&&buffer.numberOfChannels>=2;const channelLabel=channelInfo?channelInfo.label:isStereo?'STEREO':'MONO';// Mono: force volume mode (panning not applicable)
const effectiveGraphMode=!isStereo&&graphMode==='pan'?null:graphMode;useEffect(()=>{const canvas=canvasRef.current;if(!canvas||!buffer)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const wrapper=canvas.parentElement?canvas.parentElement.parentElement:null;const scrollLeft=wrapper?wrapper.scrollLeft:0;const vWidth=wrapper?wrapper.clientWidth:1200;const drawWidth=Math.min(timelineWidth,Math.max(vWidth,1200));const h=canvas.parentElement?canvas.parentElement.clientHeight:200;canvas.width=Math.round(drawWidth*dpr);canvas.height=Math.round(h*dpr);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.position='absolute';canvas.style.left=`${scrollLeft}px`;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${h}px`;ctx.fillStyle='#181818';ctx.fillRect(0,0,drawWidth,h);const data=buffer.getChannelData(0);const len=data.length;if(len===0)return;// Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline