fix&feat: hiển thị tools chỉnh sửa audioclip

This commit is contained in:
2026-07-19 10:19:25 +07:00
parent 615e0e8530
commit 95dc9346ef
6 changed files with 5146 additions and 36 deletions
+51
View File
@@ -31,6 +31,57 @@ class SubTabDSPEngine:
gain = target_amplitude / max_amplitude
return y * gain
@staticmethod
def apply_volume_automation_envelope(y: np.ndarray, sr: int, nodes: list) -> np.ndarray:
"""
Applies a user-drawn volume automation envelope onto an acoustic signal NumPy array.
nodes: A list of point dictionaries, e.g., [{"time": 0.0, "db": 0.0}, {"time": 2.5, "db": -12.0}, ...]
"""
if not nodes:
return y
# Sort envelope nodes chronologically by time axis
nodes = sorted(nodes, key=lambda x: x["time"])
# 1. Map node variables into distinct coordinates arrays
node_times = np.array([node["time"] for node in nodes])
node_dbs = np.array([node["db"] for node in nodes])
# Hard-clamp boundary constraints matching the operational floor [-30.0dB, +3.0dB]
node_dbs = np.clip(node_dbs, -30.0, 3.0)
# 2. Evaluate absolute timeline timestamps for every index position inside the signal array
total_samples = len(y)
sample_times = np.arange(total_samples) / sr
# 3. Linearly interpolate localized decibel thresholds across every single sample step
# Handle edge cases for interpolation: if sample_times is outside node_times range,
# np.interp uses the first/last value of node_dbs.
interpolated_dbs = np.interp(sample_times, node_times, node_dbs, left=node_dbs[0], right=node_dbs[-1])
# 4. Map logarithmic values into standard linear gain scale arrays
linear_gains = 10.0 ** (interpolated_dbs / 20.0)
# 5. Multiply the raw amplitude vector array by the linear gain modifier mask
return y * linear_gains
@staticmethod
def pitch_shift(y: np.ndarray, sr: int, n_steps: float) -> np.ndarray:
"""
Shift the pitch of an audio signal by a specified number of semitones.
Args:
y: Input audio signal
sr: Sample rate
n_steps: Number of semitones to shift (positive = higher pitch, negative = lower pitch)
Returns:
Pitch-shifted audio signal
"""
if n_steps == 0:
return y
return librosa.effects.pitch_shift(y, sr=sr, n_steps=n_steps)
@staticmethod
def merge_back_to_parent(
parent_track_audio: np.ndarray,
+135 -18
View File
@@ -779,6 +779,41 @@
);
};
const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade }) => (
<div className="daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800">
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
<button className={`p-1.5 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('select')} title="Select"><i data-lucide="mouse-pointer" className="w-4 h-4"></i></button>
<button className={`p-1.5 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('grab')} title="Grab"><i data-lucide="hand" className="w-4 h-4"></i></button>
<button className={`p-1.5 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('razor')} title="Razor"><i data-lucide="scissors" className="w-4 h-4"></i></button>
<button className={`p-1.5 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('pen')} title="Pen Tool"><i data-lucide="pen-tool" className="w-4 h-4"></i></button>
</div>
<div className="flex items-center space-x-3 flex-1 overflow-x-auto no-scrollbar">
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Normalize:</span>
<input type="range" min="-12" max="0" value={st.effects?.normalizeDb || 0} step="0.1" onChange={(e) => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Gain:</span>
<input type="range" min="-40" max="24" value={st.effects?.gainDb || 0} step="0.1" onChange={(e) => handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Pitch:</span>
<input type="range" min="-12" max="12" value={st.effects?.pitch || 0} step="0.1" onChange={(e) => handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Stretch:</span>
<input type="range" min="50" max="200" value={st.effects?.speedStretch || 100} step="1" onChange={(e) => handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/>
</div>
</div>
<div className="flex items-center space-x-1 pl-3 border-l border-zinc-700">
<button onClick={() => handleSubTabFade(st.id, 'in')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade In</button>
<button onClick={() => handleSubTabFade(st.id, 'out')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade Out</button>
</div>
</div>
);
const App = () => {
// ── State Definitions ──
const [tracks, setTracks] = useState([
@@ -810,14 +845,6 @@
snapValueRef.current = snapValue;
const bpmRef = useRef(bpm);
bpmRef.current = bpm;
useEffect(() => {
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, 50);
}, [activeTool]);
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [selectedTrackId, setSelectedTrackId] = useState('1');
const [currentTime, setCurrentTime] = useState(0);
@@ -945,6 +972,15 @@
fadeOutMs: 0,
});
// Lucide icons initialization
useEffect(() => {
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, 50);
}, [activeTool, activeTab]);
const timelineWrapperRef = useRef(null);
const tcpContainerRef = useRef(null);
@@ -1380,7 +1416,7 @@
startTime: selLeft,
endTime: selRight,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
@@ -1421,7 +1457,7 @@
startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
@@ -1466,14 +1502,58 @@
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
}
// Fade out
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
// Fade out
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
// Normalize (apply to selection or entire buffer)
if (fx.normalizeDb !== 0) {
const targetDb = fx.normalizeDb;
let maxVal = 0;
for (let i = 0; i < edData.length; i++) {
const abs = Math.abs(edData[i]);
if (abs > maxVal) maxVal = abs;
}
if (maxVal > 0) {
const targetAmp = Math.pow(10, targetDb / 20);
const scale = targetAmp / maxVal;
for (let i = 0; i < edData.length; i++) {
edData[i] = Math.max(-1, Math.min(1, edData[i] * scale));
}
}
}
// Pitch shift (simple resample)
if (fx.pitch !== 0) {
const ratio = Math.pow(2, fx.pitch / 12);
const newLength = Math.round(edData.length / ratio);
const newData = new Float32Array(newLength);
for (let i = 0; i < newLength; i++) {
const srcIdx = i * ratio;
const idx0 = Math.floor(srcIdx);
const idx1 = Math.min(idx0 + 1, edData.length - 1);
const frac = srcIdx - idx0;
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
}
edData.set(newData);
}
// Speed stretch
if (fx.speedStretch !== 100) {
const ratio = fx.speedStretch / 100;
const newLength = Math.round(edData.length * ratio);
const newData = new Float32Array(newLength);
for (let i = 0; i < newLength; i++) {
const srcIdx = i / ratio;
const idx0 = Math.floor(srcIdx);
const idx1 = Math.min(idx0 + 1, edData.length - 1);
const frac = srcIdx - idx0;
newData[i] = edData[idx0] * (1 - frac) + edData[idx1] * frac;
}
edData.set(newData);
}
if (subTab.clipId) {
// Clip-based merge
@@ -1549,6 +1629,33 @@
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s));
};
const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => {
updateSubTabEffects(tabId, { normalizeDb });
};
const handleSubTabGainWithValue = (tabId, gainDb) => {
updateSubTabEffects(tabId, { gainDb });
};
const handleSubTabPitch = (tabId, pitch) => {
updateSubTabEffects(tabId, { pitch });
};
const handleSubTabStretch = (tabId, speedStretch) => {
updateSubTabEffects(tabId, { speedStretch });
};
const handleSubTabFadeState = (tabId, type) => {
const st = subTabs.find(s => s.id === tabId);
if (!st) return;
const fx = st.effects || {};
if (type === 'in') {
updateSubTabEffects(tabId, { fadeInMs: (fx.fadeInMs || 0) + 10 });
} else if (type === 'out') {
updateSubTabEffects(tabId, { fadeOutMs: (fx.fadeOutMs || 0) + 10 });
}
};
// ── Context Menu Handlers ──
const handleContextMenu = (e, trackId, clickTime) => {
e.preventDefault();
@@ -4318,6 +4425,16 @@
</button>
</div>
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
<SubTabToolbar
st={st}
activeTool={activeTool}
setActiveTool={setActiveTool}
handleSubTabNormalizeWithValue={handleSubTabNormalizeWithValue}
handleSubTabGainWithValue={handleSubTabGainWithValue}
handleSubTabPitch={handleSubTabPitch}
handleSubTabStretch={handleSubTabStretch}
handleSubTabFade={handleSubTabFadeState}
/>
<SubTabWaveform
buffer={st.buffer}
subTabId={st.id}