From e0742d013c0f84457dab3eddf0e438b9ed43e648 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 18 Jul 2026 18:16:14 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20s=E1=BB=ADa=20l=E1=BB=97i=20audio=20clip?= =?UTF-8?q?=20d=C3=A1n=20t=E1=BB=AB=20clipboard=20kh=C3=B4ng=20=C4=91?= =?UTF-8?q?=C3=BAng=20dung=20l=C6=B0=E1=BB=A3ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/index.html | 130 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/app/templates/index.html b/app/templates/index.html index d310ba6..a4e9226 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -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 ( + { + 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 @@
+ {/* Tempo Track TCP - LOOP_EDITOR_2.md §6 */} +
+
+
+ TM + Tempo Track +
+
+ 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" + /> + BPM +
+
+
+ {tracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return ( @@ -2266,6 +2388,12 @@ {/* Stacked Lanes */}
+ {/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */} +
+ +
+ {tracks.map((track) => { const isSelected = selectedTrackId === track.id; return (