fix: sửa lỗi audio clip dán từ clipboard không đúng dung lượng

This commit is contained in:
2026-07-18 18:16:14 +07:00
parent 89f1c14c2e
commit e0742d013c
+129 -1
View File
@@ -67,6 +67,27 @@
return `${m}:${s.toString().padStart(2, '0')}.${ms}`;
};
const formatBeat = (secs, bpmVal) => {
if (isNaN(secs) || secs < 0) return "1.1.1";
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const bar = Math.floor(secs / barDuration) + 1;
const beat = Math.floor((secs % barDuration) / beatDuration) + 1;
const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1;
return `${bar}.${beat}.${sub}`;
};
const getBeatMarkers = (maxDur, bpmVal) => {
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const markers = [];
for (let t = 0; t <= maxDur; t += beatDuration) {
const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001;
markers.push({ time: t, isBar, beatNum: Math.floor(t / beatDuration) + 1 });
}
return markers;
};
const findZeroCrossing = (buffer, targetTime) => {
if (!buffer) return targetTime;
const sampleRate = buffer.sampleRate;
@@ -288,6 +309,80 @@
);
};
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet }) => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = timelineWidth;
const parent = canvas.parentElement;
const height = parent ? parent.clientHeight : 40;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4;
const totalSec = width / zoom;
for (let t = 0; t <= totalSec; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
const isBar = beatNum % 4 === 1;
const x = t * zoom;
if (isBar) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = 'bold 9px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${Math.ceil(beatNum / 4)}`, x + 3, 11);
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${bpm} BPM`, width - 6, 12);
}, [bpm, zoom, timelineWidth]);
return (
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={(e) => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
onPlayheadSet(time);
e.stopPropagation();
}}
/>
);
};
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ──
const SubTabWaveform = ({ buffer }) => {
const canvasRef = useRef(null);
@@ -337,6 +432,8 @@
{ id: '1', name: 'Track 01', buffer: null, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
{ id: '2', name: 'Track 02', buffer: null, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
]);
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [selectedTrackId, setSelectedTrackId] = useState('1');
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -978,6 +1075,9 @@
return max;
}, [tracks]);
const maxDurationRef = useRef(maxDuration);
maxDurationRef.current = maxDuration;
const timelineWidth = useMemo(() => Math.max(zoom * maxDuration, 1200), [zoom, maxDuration]);
const minZoom = useMemo(() => Math.max(20, 1200 / maxDuration), [maxDuration]);
@@ -1126,7 +1226,7 @@
}
}
if (updatedTime >= maxDuration) {
if (updatedTime >= maxDurationRef.current) {
stopAllPlayback();
setCurrentTime(0);
return;
@@ -2159,6 +2259,28 @@
</div>
<div className="flex flex-col divide-y divide-[#141414]">
{/* Tempo Track TCP - LOOP_EDITOR_2.md §6 */}
<div className="h-[40px] p-2 flex flex-col justify-between bg-[#1a1a2e] border-l-4 border-purple-500">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-purple-400 font-mono">TM</span>
<span className="text-xs font-semibold text-zinc-300">Tempo Track</span>
</div>
<div className="flex items-center gap-1">
<input
type="number"
value={bpm}
onChange={(e) => setBpm(e.target.value)}
onBlur={() => localStorage.setItem('studio_bpm', bpm)}
className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500"
min="40"
max="300"
/>
<span className="text-[9px] text-zinc-500">BPM</span>
</div>
</div>
</div>
{tracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return (
@@ -2266,6 +2388,12 @@
{/* Stacked Lanes */}
<div className="flex-1 flex flex-col relative divide-y divide-[#141414]">
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
<div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
onPlayheadSet={setCurrentTime} />
</div>
{tracks.map((track) => {
const isSelected = selectedTrackId === track.id;
return (