fix: TCP của subtab

This commit is contained in:
2026-07-19 21:58:16 +07:00
parent 283555c78e
commit 7d9267d175
+307 -91
View File
@@ -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</button>
</div>
</div>
<div className="flex flex-col gap-0.5 text-[10px] mb-2">
<div className="flex flex-col gap-2.5 text-[14px] mb-3">
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
<input type="range" min="-50" max="7" step="0.5" value={vTrack.volumeDb ?? 0} onChange={e => 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' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.volumeDb ?? 0}dB</span>
<span className="w-10 text-right text-zinc-500 text-[14px]">Vol:</span>
<input type="range" min="-50" max="7" step="0.5" id={`tcp-vol-${st.id}`} value={vTrack.volumeDb ?? 0} onChange={e => 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' }} />
<span className="w-16 text-right font-mono text-zinc-300 text-[14px]" id={`tcp-vol-label-${st.id}`}>{vTrack.volumeDb ?? 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" value={vTrack.pan ?? 0} onChange={e => 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' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
<span className="w-10 text-right text-zinc-500 text-[14px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" id={`tcp-pan-${st.id}`} value={vTrack.pan ?? 0} onChange={e => 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' }} />
<span className="w-16 text-right font-mono text-zinc-300 text-[14px]" id={`tcp-pan-label-detailed-${st.id}`}>{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Norm:</span>
<input type="range" min="-12" max="0" step="0.1" value={(st.effects || {}).normalizeDb || 0} onChange={e => 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' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{(st.effects || {}).normalizeDb || 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Gain:</span>
<input type="range" min="-40" max="24" step="0.1" value={(st.effects || {}).gainDb || 0} onChange={e => 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' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{(st.effects || {}).gainDb || 0}dB</span>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5 text-[14px]">
<button onClick={() => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, isLooping: !s.isLooping} : s))}
className={`px-1.5 py-0.5 text-[9px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}><i data-lucide="repeat" className="w-3 h-3"></i></button>
<span className="w-8 text-right text-zinc-500 text-[9px]">Loop:</span>
<input type="number" min="0" max="999" value={st.loopCount || 0} onChange={e => 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'}`}><i data-lucide="repeat" className="w-3.5 h-3.5"></i></button>
<button onClick={() => updateSubTabEffects(st.id, { reverse: !((st.effects || {}).reverse) })}
className={`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`}
title="Reverse">
<i data-lucide="arrow-left-right" className="w-3.5 h-3.5"></i>
</button>
<span className="w-10 text-right text-zinc-500 text-[14px]">Loop:</span>
<input type="number" min="0" max="999" value={st.loopCount || 0} onChange={e => 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" />
</div>
</div>
<div className="flex items-center gap-1 text-[9px] text-zinc-400">
<span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span>
<span className="font-mono text-zinc-300">{st.buffer ? formatTime(st.buffer.duration) : '0s'}</span>
</div>
<div className="flex items-center gap-1 text-[9px] text-zinc-400">
<span className="text-[8px] text-zinc-500 font-semibold uppercase">SR:</span>
<span className="font-mono text-zinc-300">{st.buffer ? st.buffer.sampleRate : 0} Hz</span>
<div className="flex gap-1 justify-between my-2.5">
{/* Normalize Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Normalize">Norm</span>
<input type="range" min="-12" max="0" step="0.1" value={subTabNormVal} onChange={e => setSubTabNormVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabNormVal}dB</span>
<button onClick={() => applySubTabEffect(st.id, 'normalize', subTabNormVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
{/* Pitch Shift Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Pitch Shift">Pitch</span>
<input type="range" min="-12" max="12" step="0.5" value={subTabPitchVal} onChange={e => setSubTabPitchVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabPitchVal > 0 ? '+' : ''}{subTabPitchVal}st</span>
<button onClick={() => applySubTabEffect(st.id, 'pitch', subTabPitchVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
{/* Gain Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Gain Multiplier">Gain</span>
<input type="range" min="0" max="150" step="1" value={subTabGainVal} onChange={e => setSubTabGainVal(parseInt(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabGainVal}%</span>
<button onClick={() => applySubTabEffect(st.id, 'gain', subTabGainVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
</div>
<div className="flex-1"></div>
<div className="flex gap-1 mt-2">
<button onClick={() => applySubTab(st.id)}
className="flex-1 py-1 bg-amber-700 hover:bg-amber-600 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1">
<i data-lucide="check" className="w-3 h-3"></i> Apply
<div className="mt-auto pt-2.5 border-t border-zinc-800">
<div className="flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase">
<span>Duration:</span>
<span className="font-mono text-zinc-300">{st.buffer ? formatTime(st.buffer.duration) : '0s'}</span>
</div>
<div className="flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase">
<span>SR:</span>
<span className="font-mono text-zinc-300">{st.buffer ? st.buffer.sampleRate : 0} Hz</span>
</div>
<button onClick={() => exportSubTabBuffer(st.id)}
className="w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition">
<i data-lucide="download" className="w-4 h-4"></i> Export
</button>
<button onClick={() => updateSubTabEffects(st.id, { reverse: !((st.effects || {}).reverse) })}
className={`p-1 rounded text-[9px] border ${(st.effects || {}).reverse ? 'bg-amber-800 text-amber-100 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}>
<i data-lucide="arrow-left-right" className="w-3 h-3"></i>
<button onClick={() => applySubTab(st.id)}
className="w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition">
<i data-lucide="save" className="w-4 h-4"></i> Save
</button>
</div>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-[10px] text-zinc-500">Track not found</div>
)}
@@ -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;}}
/>
<div onMouseDown={handleSubTabResizeMouseDown}
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" />