FIX: chỉnh sửa mute và unmute realtime, sửa lỗi save dự án và chèn chung các items vào một track
This commit is contained in:
+225
-61
@@ -94,6 +94,26 @@ function setMasteringRoute(route, bypass) {
|
||||
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02);
|
||||
}
|
||||
|
||||
// Realtime mute/solo: audible linear gain for a track given the full track list
|
||||
// of the CURRENT context. Solo semantics: if ANY track is soloed, only soloed
|
||||
// tracks are audible; muted tracks are always silent.
|
||||
function computeTrackAudibleGain(trackList, track) {
|
||||
if (!track) return 0;
|
||||
if (track.muted) return 0;
|
||||
const hasSolo = (trackList || []).some(t => t.solo);
|
||||
if (hasSolo && !track.solo) return 0;
|
||||
const volDb = track.volumeDb ?? 0;
|
||||
return volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
|
||||
}
|
||||
|
||||
// Apply a gain to a track node's gain with a short crossfade (click-free).
|
||||
function setTrackNodeGain(node, gainLinear) {
|
||||
if (!node || !node.gainNode || !audioCtx) return;
|
||||
const t = audioCtx.currentTime;
|
||||
node.gainNode.gain.cancelScheduledValues(t);
|
||||
node.gainNode.gain.setTargetAtTime(gainLinear, t, 0.02);
|
||||
}
|
||||
|
||||
function makeDistortionCurve(k) {
|
||||
const n_samples = 44100;
|
||||
const curve = new Float32Array(n_samples);
|
||||
@@ -811,14 +831,14 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
React.createElement("div", {
|
||||
className: "flex items-center justify-center gap-1 py-0.5 shrink-0"
|
||||
}, React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
|
||||
title: "Mute",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "M"), React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
||||
title: "Solo",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "S"), React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); const next = !track.muted; if (onUpdateTrack) onUpdateTrack(track.id, { muted: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { muted: next }); },
|
||||
title: "Mute",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "M"), React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); const next = !track.solo; if (onUpdateTrack) onUpdateTrack(track.id, { solo: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { solo: next }); },
|
||||
title: "Solo",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "S"), React.createElement("button", {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
const next = !track.masteringBypass;
|
||||
@@ -1179,6 +1199,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
var trackColor = track.color || '#06b6d4';
|
||||
var isMuted = track.muted;
|
||||
var isSoloed = track.solo;
|
||||
var isBypassed = track.masteringBypass;
|
||||
var isArmed = track.isArmed;
|
||||
var trackName = track.name || 'Track ' + (index + 1);
|
||||
var isMicActive = track.inputSource?.deviceType === 'MICROPHONE';
|
||||
@@ -1305,18 +1326,25 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
/* Right Button Stack */
|
||||
React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" },
|
||||
React.createElement("button", {
|
||||
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
|
||||
onClick: function(e) { e.stopPropagation(); var next = !track.muted; if (onUpdateTrack) onUpdateTrack(track.id, { muted: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { muted: next }); },
|
||||
className: "btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" + (isMuted ? " btn-mute-active" : ""),
|
||||
title: "Mute Track"
|
||||
}, "M"),
|
||||
React.createElement("button", {
|
||||
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
||||
onClick: function(e) { e.stopPropagation(); var next = !track.solo; if (onUpdateTrack) onUpdateTrack(track.id, { solo: next }); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(track.id, { solo: next }); },
|
||||
className: "btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" + (isSoloed ? " btn-solo-active" : ""),
|
||||
title: "Solo Track"
|
||||
}, "S"),
|
||||
React.createElement("button", {
|
||||
className: "btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center",
|
||||
title: "Routing Matrix"
|
||||
onClick: function(e) {
|
||||
e.stopPropagation();
|
||||
var next = !track.masteringBypass;
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
|
||||
// Live re-route: bypassed channel skips FX + mastering chain at Main out.
|
||||
if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
|
||||
},
|
||||
className: "btn-daw h-[24px] rounded text-sky-400 flex items-center justify-center" + (isBypassed ? " btn-bypass-active bg-sky-500/20" : ""),
|
||||
title: "Bypass: track KHÔNG qua FX + mastering ở Main out"
|
||||
}, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })),
|
||||
React.createElement("button", {
|
||||
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]",
|
||||
@@ -1399,6 +1427,7 @@ const WaveformLane = ({
|
||||
onDeselectItem,
|
||||
onAddToSelection,
|
||||
onSetPendingDrag,
|
||||
onSetPendingDragMove,
|
||||
onSetSelectionMode,
|
||||
onSetSelectionStart,
|
||||
onSetSelectionEnd,
|
||||
@@ -2110,7 +2139,10 @@ const WaveformLane = ({
|
||||
}
|
||||
if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle);
|
||||
} else {
|
||||
// Click: select this item (clear others if not already selected), then start drag
|
||||
// Click: select this item (clear others if not already selected).
|
||||
// Drag only starts after a small movement threshold (5px) — click alone
|
||||
// just selects; this unifies section/MIDI/clip behavior and stops
|
||||
// accidental moves from mouse jitter.
|
||||
if (!selectedItemIds || !selectedItemIds.has(hitItem.id)) {
|
||||
if (onClearSelection) onClearSelection();
|
||||
if (onAddToSelection) onAddToSelection(hitItem.id);
|
||||
@@ -2119,7 +2151,7 @@ const WaveformLane = ({
|
||||
var dragIds = (selectedItemIds && selectedItemIds.has(hitItem.id) && selectedItemIds.size > 1)
|
||||
? selectedItemIds
|
||||
: new Set([hitItem.id]);
|
||||
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start, false, dragIds);
|
||||
if (onSetPendingDragMove) onSetPendingDragMove(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, dragIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2171,24 +2203,49 @@ const WaveformLane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for click drag clip (Alt to move, Ctrl to duplicate)
|
||||
if (clickedClip && (e.altKey || e.ctrlKey)) {
|
||||
// Check for click drag clip (plain click = move, Alt = sweep-select duration, Ctrl = duplicate)
|
||||
if (clickedClip && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
|
||||
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
|
||||
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
|
||||
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
|
||||
if (onDeselectItem) onDeselectItem(clipCanonicalId);
|
||||
} else if (onAddToSelection) {
|
||||
onAddToSelection(clipCanonicalId);
|
||||
}
|
||||
if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot);
|
||||
} else {
|
||||
// Alt+Click: move immediately
|
||||
if (onClipDragStart) onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, false);
|
||||
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
|
||||
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
|
||||
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
|
||||
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
|
||||
if (onDeselectItem) onDeselectItem(clipCanonicalId);
|
||||
} else if (onAddToSelection) {
|
||||
onAddToSelection(clipCanonicalId);
|
||||
}
|
||||
if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+Click on a clip: sweep-select duration (the OLD plain click+drag
|
||||
// behavior moved here). Plain click+drag now MOVES the clip like
|
||||
// section/MIDI items.
|
||||
if (clickedClip && e.altKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPlayheadSet(time);
|
||||
if (onTrackLaneMouseDown) {
|
||||
onTrackLaneMouseDown(track.id, time, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain click on a clip: select it; drag after a small movement
|
||||
// threshold moves it (unified with section/MIDI items).
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var clipCanonicalId2 = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
|
||||
if (!selectedItemIds || !selectedItemIds.has(clipCanonicalId2)) {
|
||||
if (onClearSelection) onClearSelection();
|
||||
if (onAddToSelection) onAddToSelection(clipCanonicalId2);
|
||||
}
|
||||
var clipDragIds = (selectedItemIds && selectedItemIds.has(clipCanonicalId2) && selectedItemIds.size > 1)
|
||||
? selectedItemIds
|
||||
: new Set([clipCanonicalId2]);
|
||||
if (onSetPendingDragMove) onSetPendingDragMove(track.id, 'clip', clipCanonicalId2, time - clickedClip.startTime, e.nativeEvent || e, clipDragIds);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7972,7 +8029,10 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
|
||||
|
||||
const items = [];
|
||||
if (trackType === "AUDIO" && t.clips) {
|
||||
// Serialize EVERY item type present on the track (a track can hold audio
|
||||
// clips + MIDI items + section items at once). The old if/else-if chain
|
||||
// dropped all but one type per track — silent data loss on save.
|
||||
if (t.clips && t.clips.length > 0) {
|
||||
t.clips.forEach(c => {
|
||||
const durationSec = c.buffer ? c.buffer.duration : 4.0;
|
||||
const clipFileId = c.serverFileId || t.serverFileId;
|
||||
@@ -7992,7 +8052,8 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (trackType === "MIDI" && t.midiItems) {
|
||||
}
|
||||
if (t.midiItems && t.midiItems.length > 0) {
|
||||
t.midiItems.forEach(m => {
|
||||
items.push({
|
||||
id: m.id,
|
||||
@@ -8014,7 +8075,8 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (trackType === "SECTION" && t.sections) {
|
||||
}
|
||||
if (t.sections && t.sections.length > 0) {
|
||||
t.sections.forEach(s => {
|
||||
items.push({
|
||||
id: s.id,
|
||||
@@ -11783,11 +11845,55 @@ const App = () => {
|
||||
if (node && node.route) setMasteringRoute(node.route, !!bypass);
|
||||
};
|
||||
|
||||
// Keep the routing map in sync with the tracks state (loads, undo, AI ops…).
|
||||
useEffect(() => {
|
||||
(tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
|
||||
(sessionTabs || []).forEach(st => (st.tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }));
|
||||
}, [tracks, sessionTabs]);
|
||||
// Realtime mute/solo: applies the (patched) mute/solo state to every active
|
||||
// track node in the current context. Called from the M/S buttons of the
|
||||
// mixer strips + track strips so toggling muting/soloing affects the items
|
||||
// already playing on that track immediately (20ms crossfade, no click).
|
||||
const applyAllTrackMuteSolo = (trackId, patch) => {
|
||||
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||
const effective = (trackId && patch) ? list.map(t => t.id === trackId ? { ...t, ...patch } : t) : list;
|
||||
let becameAudible = false;
|
||||
effective.forEach(t => {
|
||||
const audible = computeTrackAudibleGain(effective, t) > 0;
|
||||
const wasAudible = trackAudibleRef.current[t.id] !== false;
|
||||
const node = activeTrackNodesRef.current[t.id];
|
||||
if (node) setTrackNodeGain(node, computeTrackAudibleGain(effective, t));
|
||||
// MIDI items: FluidSynth mixes ALL channels into ONE shared gain node, so
|
||||
// the per-track gain cannot silence them. Every MIDI track owns a
|
||||
// dedicated channel (ensureTrackMidiChannel) → mute/solo via CC7 (channel
|
||||
// volume) applies realtime, even to notes already sounding.
|
||||
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
|
||||
try {
|
||||
if (window.SonicSF && typeof window.SonicSF.controllerChange === 'function') {
|
||||
const ch = ensureTrackMidiChannel(t, effective);
|
||||
window.SonicSF.controllerChange(ch, 7, audible ? 100 : 0);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (audible && !wasAudible) becameAudible = true;
|
||||
trackAudibleRef.current[t.id] = audible;
|
||||
});
|
||||
// A track that just became audible may have had its audio sources dropped
|
||||
// by a loop restart while it was inaudible (startTrackPlayback skips
|
||||
// muted/soloed tracks, so unmute alone cannot resurrect them). Re-schedule
|
||||
// from the current playhead — the same approach toggleTrackSoloEvaluate
|
||||
// already uses for solo.
|
||||
if (becameAudible && isPlaying && recordingStateRef.current !== 'RECORDING' && activeTracksRef.current && activeTracksRef.current.length) {
|
||||
try {
|
||||
stopAllPlayback();
|
||||
const ctx = getAudioContext();
|
||||
const resumeAt = currentTimeRef.current || 0;
|
||||
startOffsetTimeRef.current = resumeAt;
|
||||
startAudioTimeRef.current = ctx.currentTime;
|
||||
startBufferOffsetRef.current = resumeAt;
|
||||
setIsPlaying(true);
|
||||
startTrackPlayback(resumeAt);
|
||||
} catch (e) {
|
||||
console.warn('mute/solo resume error:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__applyTrackMuteSolo = applyAllTrackMuteSolo;
|
||||
window.__toggleMediaExplorerRef = function() {
|
||||
setShowMediaExplorer(function(p) {
|
||||
const next = !p;
|
||||
@@ -12274,6 +12380,42 @@ const App = () => {
|
||||
activeTracksRef.current = activeTracks;
|
||||
const sessionTabsRef = useRef(sessionTabs);
|
||||
sessionTabsRef.current = sessionTabs;
|
||||
|
||||
// Track mute/solo/volume signature: only re-apply gains when something that
|
||||
// affects audibility actually changed (loads, undo, AI ops, section tabs).
|
||||
const trackMuteSoloSigRef = useRef({});
|
||||
// Last-known audibility per track id (used to detect inaudible→audible
|
||||
// transitions that need a playback re-schedule).
|
||||
const trackAudibleRef = useRef({});
|
||||
|
||||
// Keep the mastering-bypass routing map + realtime mute/solo in sync with the
|
||||
// tracks state (loads, undo, AI ops…). Placed AFTER the tracks/subTabs/
|
||||
// sessionTabs declarations (TDZ-safe).
|
||||
useEffect(() => {
|
||||
const all = [...(tracks || []), ...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), [])];
|
||||
all.forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
|
||||
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : all;
|
||||
list.forEach(t => {
|
||||
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
|
||||
if (trackMuteSoloSigRef.current[t.id] === sig) return;
|
||||
trackMuteSoloSigRef.current[t.id] = sig;
|
||||
const audible = computeTrackAudibleGain(list, t) > 0;
|
||||
const node = activeTrackNodesRef.current[t.id];
|
||||
if (node) setTrackNodeGain(node, computeTrackAudibleGain(list, t));
|
||||
// MIDI tracks: mirror the gain decision onto the channel CC7 volume so
|
||||
// FluidSynth-rendered notes respect mute/solo too.
|
||||
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
|
||||
try {
|
||||
if (window.SonicSF && typeof window.SonicSF.controllerChange === 'function') {
|
||||
const ch = ensureTrackMidiChannel(t, list);
|
||||
window.SonicSF.controllerChange(ch, 7, audible ? 100 : 0);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
trackAudibleRef.current[t.id] = audible;
|
||||
});
|
||||
}, [tracks, sessionTabs]);
|
||||
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
const midiVuActivityRef = useRef({});
|
||||
@@ -12398,23 +12540,23 @@ const App = () => {
|
||||
}));
|
||||
if (hasLoadedAny) {
|
||||
setTracks(prev => {
|
||||
const merged = [...updatedTracks];
|
||||
(prev || []).forEach((pt, i) => {
|
||||
if (!merged[i]) merged[i] = pt;
|
||||
else {
|
||||
merged[i] = { ...merged[i] };
|
||||
merged[i].clips = (pt.clips || []).map((pc, j) => {
|
||||
if (merged[i].clips && merged[i].clips[j] && (merged[i].clips[j].buffer || pc.buffer)) {
|
||||
return { ...pc, buffer: pc.buffer || merged[i].clips[j].buffer };
|
||||
}
|
||||
if ((pc.buffer || (merged[i].clips && merged[i].clips[j] && merged[i].clips[j].buffer))) {
|
||||
return pc;
|
||||
}
|
||||
return merged[i].clips && merged[i].clips[j] ? merged[i].clips[j] : pc;
|
||||
});
|
||||
}
|
||||
// Merge by TRACK ID (not array index): if the state changed between the
|
||||
// fetch start and now (e.g. another project opened), index-based merging
|
||||
// would scramble tracks and drop items into the wrong track. updatedTracks
|
||||
// is authoritative; patch clip buffers from prev by matching clip ids.
|
||||
const prevById = new Map((prev || []).map(pt => [pt.id, pt]));
|
||||
return updatedTracks.map(ut => {
|
||||
const pt = prevById.get(ut.id);
|
||||
if (!pt) return ut;
|
||||
const ptClips = pt.clips || [];
|
||||
const utClips = ut.clips || [];
|
||||
const mergedClips = ptClips.map(pc => {
|
||||
const uc = utClips.find(c => c.id === pc.id);
|
||||
if (uc) return { ...pc, buffer: pc.buffer || uc.buffer };
|
||||
return pc;
|
||||
});
|
||||
return { ...ut, clips: mergedClips };
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
setSessionTabs(prev => prev.map(st => ({
|
||||
...st,
|
||||
@@ -15353,6 +15495,8 @@ const App = () => {
|
||||
pannerNode,
|
||||
source
|
||||
};
|
||||
// Realtime mute/solo for the newly created playback chain.
|
||||
if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(st.trackId, null);
|
||||
startOffsetTimeRef.current = offsetWallTime;
|
||||
startBufferOffsetRef.current = offsetBuffer;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
@@ -15627,11 +15771,12 @@ const App = () => {
|
||||
const analyserNode = context.createAnalyser();
|
||||
analyserNode.fftSize = 256;
|
||||
pannerNode.connect(analyserNode);
|
||||
// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
|
||||
// dry bus (bypass). Live-toggled via setMasteringRoute(node.route, ...).
|
||||
// Dual mastering route: routeGain -> mastering chain (normal, post-FX),
|
||||
// dryGain -> dry bus (bypass, tapped PRE-FX so the bypassed channel skips
|
||||
// BOTH the track FX chain and the mastering chain at Main out).
|
||||
const route = createMasteringRoute(context, track, masterBus);
|
||||
analyserNode.connect(route.routeGain);
|
||||
analyserNode.connect(route.dryGain);
|
||||
gainNode.connect(route.dryGain);
|
||||
|
||||
let fxStopFn;
|
||||
if (track.fxType === 'chorus') {
|
||||
@@ -15648,6 +15793,10 @@ const App = () => {
|
||||
gainNode.connect(pannerNode);
|
||||
}
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
||||
// Realtime mute/solo: apply the track's current mute/solo/volume state to
|
||||
// the fresh node so items of muted/soloed tracks start correctly.
|
||||
const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track];
|
||||
setTrackNodeGain(node, computeTrackAudibleGain(trackList, track));
|
||||
activeTrackNodesRef.current[track.id] = node;
|
||||
}
|
||||
return node.gainNode;
|
||||
@@ -16912,6 +17061,8 @@ const App = () => {
|
||||
isDuplicate: false
|
||||
});
|
||||
};
|
||||
const handleClipDragStartRef = useRef(null);
|
||||
handleClipDragStartRef.current = handleClipDragStart;
|
||||
const stretchedClipRef = useRef(null);
|
||||
stretchedClipRef.current = stretchedClip;
|
||||
const handleClipStretchStart = (trackId, clipId, clickTime) => {
|
||||
@@ -17222,7 +17373,14 @@ const App = () => {
|
||||
} else {
|
||||
ids.add(itemId);
|
||||
}
|
||||
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids };
|
||||
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids, duplicate: true };
|
||||
};
|
||||
|
||||
// Plain click on an item: select only; the real drag (move) starts after a
|
||||
// 5px movement threshold. Unifies section/MIDI/clip behavior and prevents
|
||||
// accidental moves from mouse jitter.
|
||||
const handleSetPendingDragMove = (trackId, itemType, itemId, clickOffset, e, dragIds) => {
|
||||
pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: dragIds, duplicate: false };
|
||||
};
|
||||
|
||||
// ── Sweep Select ──
|
||||
@@ -17321,8 +17479,8 @@ const App = () => {
|
||||
}
|
||||
} else {
|
||||
var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null;
|
||||
var its = itemType === 'section' ? (curTrk?.sections || []) : (curTrk?.midiItems || []);
|
||||
var it = its.find(function(x) { return x.id === itemId; });
|
||||
var its = itemType === 'section' ? (curTrk?.sections || []) : (itemType === 'clip' ? (curTrk?.clips || []) : (curTrk?.midiItems || []));
|
||||
var it = its.find(function(x) { return x.id === itemId || (itemType === 'clip' && itemId === 'default_' + curTrk?.id && x.id === 'default'); });
|
||||
var origPos = it ? (itemType === 'section' ? it.start : it.startTime) : 0;
|
||||
var beforeSnap = captureAllTracksSnapshot();
|
||||
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos }, beforeSnap });
|
||||
@@ -17550,7 +17708,7 @@ const App = () => {
|
||||
};
|
||||
}, [zoom, activeTab, sessionTabs]);
|
||||
|
||||
// ── Pending drag: Ctrl+click toggles selection; mousemove > threshold starts copy-drag ──
|
||||
// ── Pending drag: click selects; mousemove > 5px threshold starts the real drag ──
|
||||
useEffect(() => {
|
||||
const handleMouseMove = e => {
|
||||
var pd = pendingDragRef.current;
|
||||
@@ -17559,7 +17717,12 @@ const App = () => {
|
||||
if (Math.abs(dx) > 5) {
|
||||
var pdSnap = pendingDragRef.current;
|
||||
pendingDragRef.current = null;
|
||||
if (handleSectionItemDragStartRef.current) handleSectionItemDragStartRef.current(pdSnap.trackId, pdSnap.itemType, pdSnap.itemId, pdSnap.clickOffset, true, pdSnap.selectedIds);
|
||||
if (pdSnap.itemType === 'clip' && !pdSnap.duplicate && (!pdSnap.selectedIds || pdSnap.selectedIds.size <= 1)) {
|
||||
// Single clip move → clip drag machinery
|
||||
if (handleClipDragStartRef.current) handleClipDragStartRef.current(pdSnap.trackId, pdSnap.itemId, pdSnap.clickOffset, false);
|
||||
} else if (handleSectionItemDragStartRef.current) {
|
||||
handleSectionItemDragStartRef.current(pdSnap.trackId, pdSnap.itemType, pdSnap.itemId, pdSnap.clickOffset, !!pdSnap.duplicate, pdSnap.selectedIds);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
@@ -22944,6 +23107,7 @@ const App = () => {
|
||||
onDeselectItem: handleDeselectItem,
|
||||
onAddToSelection: handleAddToSelection,
|
||||
onSetPendingDrag: handleSetPendingDrag,
|
||||
onSetPendingDragMove: handleSetPendingDragMove,
|
||||
onContextMenu: handleContextMenu,
|
||||
onClipDragStart: handleClipDragStart,
|
||||
onClipStretchStart: handleClipStretchStart,
|
||||
|
||||
Reference in New Issue
Block a user