fix: TCP của subtab
This commit is contained in:
+307
-91
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user