diff --git a/app/templates/index.html b/app/templates/index.html index c61c998..477d524 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -201,9 +201,11 @@ const width = timelineWidth; const height = canvas.parentElement.clientHeight; - canvas.width = width * dpr; + const maxW = 10000; + const useW = Math.min(timelineWidth, maxW); + canvas.width = useW * dpr; canvas.height = height * dpr; - ctx.scale(dpr, dpr); + ctx.scale(dpr * (useW / timelineWidth), dpr); ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d'); ctx.fillRect(0, 0, width, height); @@ -591,9 +593,11 @@ const parent = canvas.parentElement; const height = parent ? parent.clientHeight : 40; - canvas.width = width * dpr; + const maxW = 10000; + const useW = Math.min(timelineWidth, maxW); + canvas.width = useW * dpr; canvas.height = height * dpr; - ctx.scale(dpr, dpr); + ctx.scale(dpr * (useW / timelineWidth), dpr); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, width, height); @@ -694,9 +698,11 @@ const rect = canvas.getBoundingClientRect(); const w = timelineWidth; const h = rect.height; - canvas.width = w * dpr; + const maxW = 10000; + const useW = Math.min(timelineWidth, maxW); + canvas.width = useW * dpr; canvas.height = h * dpr; - ctx.scale(dpr, dpr); + ctx.scale(dpr * (useW / timelineWidth), dpr); ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h); @@ -706,50 +712,85 @@ if (len === 0) return; // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline - const getVolumeGainAtTime = (t) => { + const computeHermiteTangents = (pts) => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i-1].time; + const hN = pts[i+1].time - pts[i].time; + const sP = (pts[i].db - pts[i-1].db) / hP; + const sN = (pts[i+1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; + return m; + }; + + const getVolumeDbAtTime = (t) => { const volNodes = volumeNodes || []; - if (volNodes.length === 0) return 1.0; + if (volNodes.length === 0) return 0.0; const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); - - const computeHermiteTangents = (pts) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i-1].time; - const hN = pts[i+1].time - pts[i].time; - const sP = (pts[i].db - pts[i-1].db) / hP; - const sN = (pts[i+1].db - pts[i].db) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; - m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; - return m; - }; + if (sortedNodes.length === 1) return sortedNodes[0].db; + if (t <= sortedNodes[0].time) return sortedNodes[0].db; + if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; const tangents = computeHermiteTangents(sortedNodes); - - if (sortedNodes.length === 1) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t <= sortedNodes[0].time) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t >= sortedNodes[sortedNodes.length - 1].time) { - return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); - } for (let i = 0; i < sortedNodes.length - 1; i++) { const n1 = sortedNodes[i]; const n2 = sortedNodes[i+1]; if (t >= n1.time && t <= n2.time) { const h = n2.time - n1.time; - if (h <= 0) return Math.pow(10, n1.db / 20); + if (h <= 0) return n1.db; const frac = (t - n1.time) / h; const frac2 = frac * frac, frac3 = frac2 * frac; - const db = (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; - return Math.pow(10, db / 20); + return (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; } } - return 1.0; + return 0.0; + }; + + const getVolumeGainAtTime = (t) => { + return Math.pow(10, getVolumeDbAtTime(t) / 20); + }; + + const computeHermiteTangentsForPan = (pts) => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i-1].time; + const hN = pts[i+1].time - pts[i].time; + const sP = (pts[i].pan - pts[i-1].pan) / hP; + const sN = (pts[i+1].pan - pts[i].pan) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; + m[n-1] = n > 1 ? (pts[n-1].pan - pts[n-2].pan) / (pts[n-1].time - pts[n-2].time) : 0; + return m; + }; + + const getPanningValueAtTime = (t) => { + const panNodes = panningNodes || []; + if (panNodes.length === 0) return 0; + const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); + if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); + if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); + if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); + const tangents = computeHermiteTangentsForPan(sortedNodes); + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i+1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.round(n1.pan * 100); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, frac3 = frac2 * frac; + const val = (2*frac3 - 3*frac2 + 1) * n1.pan + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.pan + (frac3 - frac2) * h * tangents[i+1]; + return Math.round(val * 100); + } + } + return 0; }; // Draw clip container (like main session clips) @@ -911,7 +952,7 @@ } // Compute volume automation gain in real-time - const volGain = getVolumeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); const totalGain = fadeGain * volGain; let maxVal = 0; @@ -1132,7 +1173,27 @@ ctx.stroke(); } - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime]); + // Update TCP slider values in real-time to match the curve at currentTime + const volInput = document.getElementById(`tcp-vol-${subTabId}`); + const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); + if (volInput && volLabel) { + const dbVal = getVolumeDbAtTime(currentTime || 0); + volInput.value = dbVal.toFixed(1); + volLabel.textContent = `${dbVal.toFixed(1)}dB`; + } + const panInput = document.getElementById(`tcp-pan-${subTabId}`); + const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); + if (panInput && panLabel) { + const panVal = getPanningValueAtTime(currentTime || 0); + panInput.value = panVal; + panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; + const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); + if (panLabelDetailed) { + panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; + } + } + + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); const handleMouseDown = (e) => { if (e.button === 2) return; @@ -1656,9 +1717,11 @@ const rect = canvas.getBoundingClientRect(); const w = timelineWidth; const h = rect.height; - canvas.width = w * dpr; + const maxW = 10000; + const useW = Math.min(timelineWidth, maxW); + canvas.width = useW * dpr; canvas.height = h * dpr; - ctx.scale(dpr, dpr); + ctx.scale(dpr * (useW / timelineWidth), dpr); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, w, h); @@ -1984,6 +2047,9 @@ // ── Tab System (LOOP_EDITOR_2.md §1) ── const [activeTab, setActiveTab] = useState('subtab_1'); const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); + const [subTabNormVal, setSubTabNormVal] = useState(0); + const [subTabGainVal, setSubTabGainVal] = useState(100); + const [subTabPitchVal, setSubTabPitchVal] = useState(0); const [subTabs, setSubTabs] = useState([ { id: 'subtab_1', @@ -2127,6 +2193,7 @@ selectedClipIdRef.current = selectedClipId; const activeTabRef = useRef(activeTab); activeTabRef.current = activeTab; + const activePlaybackSpeedRef = useRef(1.0); const subTabsRef = useRef(subTabs); subTabsRef.current = subTabs; const subTabSelectedNodeTimeRef = useRef(null); @@ -2853,6 +2920,136 @@ showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); }; + const applySubTabEffect = (tabId, effectType, value) => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + + const ctx = getAudioContext(); + const sr = subTab.buffer.sampleRate; + const eff = subTab.buffer.getChannelData(0); + + // Clone buffer + let resultBuffer = ctx.createBuffer(1, eff.length, sr); + let resultData = resultBuffer.getChannelData(0); + resultData.set(eff); + + // Calculate selection start/end in unstretched sample indices + const speed = subTab.speed || 1.0; + const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd; + + const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0; + const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration; + + const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr))); + const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr))); + + if (effectType === 'normalize') { + let maxVal = 0; + for (let i = startSample; i < endSample; i++) { + const abs = Math.abs(resultData[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal > 0) { + const targetAmp = Math.pow(10, value / 20); + const scale = targetAmp / maxVal; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); + } + } + } else if (effectType === 'gain') { + const gainScale = value / 100; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale)); + } + } else if (effectType === 'pitch') { + const ratio = Math.pow(2, value / 12); + const applyResample = (data, r) => { + const newLen = Math.round(data.length * r); + const out = new Float32Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = i / r; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, data.length - 1); + const frac = srcIdx - idx0; + out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; + } + return out; + }; + const sub = resultData.slice(startSample, endSample); + const subResampled = applyResample(sub, 1 / ratio); + for (let i = 0; i < endSample - startSample; i++) { + resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; + } + } + + // Update subTab buffer state + setSubTabs(prev => prev.map(s => { + if (s.id !== tabId) return s; + return { + ...s, + buffer: resultBuffer, + selectionStart: null, + selectionEnd: null + }; + })); + showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success'); + }; + + const exportSubTabBuffer = async (tabId) => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + try { + const buffer = subTab.buffer; + const sr = buffer.sampleRate; + const monoData = buffer.getChannelData(0); + const bufferLength = monoData.length; + const bitDepth = 16; + const bytesPerSample = 2; + const headerSize = 44; + const fileSizeBytes = headerSize + (bufferLength * bytesPerSample); + const fileBuffer = new ArrayBuffer(fileSizeBytes); + const view = new DataView(fileBuffer); + + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); + view.setUint32(4, fileSizeBytes - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sr, true); + view.setUint32(28, sr * bytesPerSample, true); + view.setUint16(32, bytesPerSample, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, bufferLength * bytesPerSample, true); + + let offset = 44; + for (let i = 0; i < bufferLength; i++) { + const sample = Math.max(-1, Math.min(1, monoData[i])); + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + offset += bytesPerSample; + } + + const blob = new Blob([view], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${subTab.label || 'subtab-export'}.wav`; + a.click(); + URL.revokeObjectURL(url); + showToast("Xuất bản sub-tab hoàn tất!", "success"); + } catch (err) { + showToast("Lỗi xuất âm thanh: " + err.message, "error"); + } + }; + const closeSubTab = (tabId) => { setSubTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); @@ -3435,6 +3632,7 @@ const source = context.createBufferSource(); source.buffer = edBuffer; source.playbackRate.value = speed; + activePlaybackSpeedRef.current = speed; // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3) // source → volumeGainNode → pannerNode → fadeGainNode → destination @@ -3500,8 +3698,8 @@ }; const updatePlayhead = () => { - if (activeTab !== 'main') { - const st = subTabs.find(s => s.id === activeTab); + if (activeTabRef.current !== 'main') { + const st = subTabsRef.current.find(s => s.id === activeTabRef.current); if (!st || !st.isPlaying || !st.buffer) return; const context = getAudioContext(); const speedFactor = st.speed || 1.0; @@ -3515,7 +3713,7 @@ const end = Math.max(st.selectionStart, st.selectionEnd); if (bufferPos >= end) { stopAllPlayback(); - setSubTabs(prev => prev.map(s => s.id === activeTab ? { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: wallTime, isPlaying: true @@ -3529,7 +3727,7 @@ if (bufferPos >= st.buffer.duration) { stopAllPlayback(); if (isLoopingSelection) { - setSubTabs(prev => prev.map(s => s.id === activeTab ? { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: 0, isPlaying: true @@ -3537,12 +3735,12 @@ startSubTabPlayback(st, 0); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); } else { - setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s)); + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: 0, isPlaying: false } : s)); } return; } - setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: wallTime } : s)); + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: wallTime } : s)); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } @@ -5875,54 +6073,72 @@ className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S -
+
- Vol: - updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} /> - {vTrack.volumeDb ?? 0}dB + Vol: + updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} /> + {vTrack.volumeDb ?? 0}dB
- Pan: - updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} /> - {vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'} + Pan: + updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} /> + {vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}
-
- Norm: - updateSubTabEffects(st.id, { normalizeDb: parseFloat(e.target.value) })} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-amber-500" style={{ height: '4px' }} /> - {(st.effects || {}).normalizeDb || 0}dB -
-
- Gain: - updateSubTabEffects(st.id, { gainDb: parseFloat(e.target.value) })} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} /> - {(st.effects || {}).gainDb || 0}dB -
-
+
- Loop: - setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, loopCount: Math.max(0, parseInt(e.target.value) || 0)} : s))} className="w-12 bg-black text-amber-300 text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" title="Loop count" /> + className={`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}> + + Loop: + setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, loopCount: Math.max(0, parseInt(e.target.value) || 0)} : s))} className="w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" title="Loop count" />
-
- Duration: - {st.buffer ? formatTime(st.buffer.duration) : '0s'} -
-
- SR: - {st.buffer ? st.buffer.sampleRate : 0} Hz +
+ {/* Normalize Column */} +
+ Norm + setSubTabNormVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> + {subTabNormVal}dB + +
+ {/* Pitch Shift Column */} +
+ Pitch + setSubTabPitchVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> + {subTabPitchVal > 0 ? '+' : ''}{subTabPitchVal}st + +
+ {/* Gain Column */} +
+ Gain + setSubTabGainVal(parseInt(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> + {subTabGainVal}% + +
-
- -
-
+
) : (
Track not found
)} @@ -6001,13 +6217,13 @@ if (n && n.source) n.source.playbackRate.value = newSpeed; // Reset time refs to prevent playhead jump when speed changes mid-playback const ctx = getAudioContext(); - const elapsed = ctx.currentTime - startAudioTimeRef.current; - const oldSpeed = st.speed || 1.0; - const ratio = oldSpeed / newSpeed; - startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; - startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; - startAudioTimeRef.current = ctx.currentTime; - }} + const elapsed = ctx.currentTime - startAudioTimeRef.current; + const oldSpeed = activePlaybackSpeedRef.current; + const ratio = oldSpeed / newSpeed; + startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; + startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; + startAudioTimeRef.current = ctx.currentTime; + activePlaybackSpeedRef.current = newSpeed;}} />