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:
+10
-5
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
return project_data
|
return project_data
|
||||||
|
|
||||||
tracks = project_data.get("tracks", [])
|
tracks = project_data.get("tracks", [])
|
||||||
|
# Legacy format stores item start times in SECONDS; convert using the real
|
||||||
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||||
|
# position for any tempo other than the one where 1 bar = 4s).
|
||||||
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||||
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||||
upgraded_tracks = []
|
upgraded_tracks = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track_id = str(t.get("id", ""))
|
track_id = str(t.get("id", ""))
|
||||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"name": c.get("name", "Audio Clip"),
|
"name": c.get("name", "Audio Clip"),
|
||||||
"type": "AUDIO_ITEM",
|
"type": "AUDIO_ITEM",
|
||||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": 4.0,
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||||
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": m.get("id"),
|
"id": m.get("id"),
|
||||||
"name": m.get("name", "MIDI Item"),
|
"name": m.get("name", "MIDI Item"),
|
||||||
"type": "MIDI_ITEM",
|
"type": "MIDI_ITEM",
|
||||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": m.get("duration", 4.0),
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"total_buffer_bars": m.get("duration", 8.0),
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||||
"notes": m.get("notes", [])
|
"notes": m.get("notes", [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+225
-61
@@ -94,6 +94,26 @@ function setMasteringRoute(route, bypass) {
|
|||||||
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02);
|
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) {
|
function makeDistortionCurve(k) {
|
||||||
const n_samples = 44100;
|
const n_samples = 44100;
|
||||||
const curve = new Float32Array(n_samples);
|
const curve = new Float32Array(n_samples);
|
||||||
@@ -811,14 +831,14 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
|||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
className: "flex items-center justify-center gap-1 py-0.5 shrink-0"
|
className: "flex items-center justify-center gap-1 py-0.5 shrink-0"
|
||||||
}, React.createElement("button", {
|
}, React.createElement("button", {
|
||||||
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
|
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",
|
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')
|
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", {
|
}, "M"), React.createElement("button", {
|
||||||
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
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",
|
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')
|
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", {
|
}, "S"), React.createElement("button", {
|
||||||
onClick: e => {
|
onClick: e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const next = !track.masteringBypass;
|
const next = !track.masteringBypass;
|
||||||
@@ -1179,6 +1199,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
|||||||
var trackColor = track.color || '#06b6d4';
|
var trackColor = track.color || '#06b6d4';
|
||||||
var isMuted = track.muted;
|
var isMuted = track.muted;
|
||||||
var isSoloed = track.solo;
|
var isSoloed = track.solo;
|
||||||
|
var isBypassed = track.masteringBypass;
|
||||||
var isArmed = track.isArmed;
|
var isArmed = track.isArmed;
|
||||||
var trackName = track.name || 'Track ' + (index + 1);
|
var trackName = track.name || 'Track ' + (index + 1);
|
||||||
var isMicActive = track.inputSource?.deviceType === 'MICROPHONE';
|
var isMicActive = track.inputSource?.deviceType === 'MICROPHONE';
|
||||||
@@ -1305,18 +1326,25 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
|||||||
/* Right Button Stack */
|
/* Right Button Stack */
|
||||||
React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" },
|
React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" },
|
||||||
React.createElement("button", {
|
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" : ""),
|
className: "btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" + (isMuted ? " btn-mute-active" : ""),
|
||||||
title: "Mute Track"
|
title: "Mute Track"
|
||||||
}, "M"),
|
}, "M"),
|
||||||
React.createElement("button", {
|
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" : ""),
|
className: "btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" + (isSoloed ? " btn-solo-active" : ""),
|
||||||
title: "Solo Track"
|
title: "Solo Track"
|
||||||
}, "S"),
|
}, "S"),
|
||||||
React.createElement("button", {
|
React.createElement("button", {
|
||||||
className: "btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center",
|
onClick: function(e) {
|
||||||
title: "Routing Matrix"
|
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("i", { className: "fa-solid fa-bars-staggered text-[8px]" })),
|
||||||
React.createElement("button", {
|
React.createElement("button", {
|
||||||
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]",
|
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,
|
onDeselectItem,
|
||||||
onAddToSelection,
|
onAddToSelection,
|
||||||
onSetPendingDrag,
|
onSetPendingDrag,
|
||||||
|
onSetPendingDragMove,
|
||||||
onSetSelectionMode,
|
onSetSelectionMode,
|
||||||
onSetSelectionStart,
|
onSetSelectionStart,
|
||||||
onSetSelectionEnd,
|
onSetSelectionEnd,
|
||||||
@@ -2110,7 +2139,10 @@ const WaveformLane = ({
|
|||||||
}
|
}
|
||||||
if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle);
|
if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle);
|
||||||
} else {
|
} 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 (!selectedItemIds || !selectedItemIds.has(hitItem.id)) {
|
||||||
if (onClearSelection) onClearSelection();
|
if (onClearSelection) onClearSelection();
|
||||||
if (onAddToSelection) onAddToSelection(hitItem.id);
|
if (onAddToSelection) onAddToSelection(hitItem.id);
|
||||||
@@ -2119,7 +2151,7 @@ const WaveformLane = ({
|
|||||||
var dragIds = (selectedItemIds && selectedItemIds.has(hitItem.id) && selectedItemIds.size > 1)
|
var dragIds = (selectedItemIds && selectedItemIds.has(hitItem.id) && selectedItemIds.size > 1)
|
||||||
? selectedItemIds
|
? selectedItemIds
|
||||||
: new Set([hitItem.id]);
|
: 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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2171,24 +2203,49 @@ const WaveformLane = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for click drag clip (Alt to move, Ctrl to duplicate)
|
// Check for click drag clip (plain click = move, Alt = sweep-select duration, Ctrl = duplicate)
|
||||||
if (clickedClip && (e.altKey || e.ctrlKey)) {
|
if (clickedClip && e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.ctrlKey) {
|
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
|
||||||
// Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot
|
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
|
||||||
var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id;
|
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
|
||||||
var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set();
|
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
|
||||||
if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) {
|
if (onDeselectItem) onDeselectItem(clipCanonicalId);
|
||||||
if (onDeselectItem) onDeselectItem(clipCanonicalId);
|
} else if (onAddToSelection) {
|
||||||
} else if (onAddToSelection) {
|
onAddToSelection(clipCanonicalId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7972,7 +8029,10 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
|||||||
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
|
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
|
||||||
|
|
||||||
const items = [];
|
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 => {
|
t.clips.forEach(c => {
|
||||||
const durationSec = c.buffer ? c.buffer.duration : 4.0;
|
const durationSec = c.buffer ? c.buffer.duration : 4.0;
|
||||||
const clipFileId = c.serverFileId || t.serverFileId;
|
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 => {
|
t.midiItems.forEach(m => {
|
||||||
items.push({
|
items.push({
|
||||||
id: m.id,
|
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 => {
|
t.sections.forEach(s => {
|
||||||
items.push({
|
items.push({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -11783,11 +11845,55 @@ const App = () => {
|
|||||||
if (node && node.route) setMasteringRoute(node.route, !!bypass);
|
if (node && node.route) setMasteringRoute(node.route, !!bypass);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the routing map in sync with the tracks state (loads, undo, AI ops…).
|
// Realtime mute/solo: applies the (patched) mute/solo state to every active
|
||||||
useEffect(() => {
|
// track node in the current context. Called from the M/S buttons of the
|
||||||
(tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
|
// mixer strips + track strips so toggling muting/soloing affects the items
|
||||||
(sessionTabs || []).forEach(st => (st.tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }));
|
// already playing on that track immediately (20ms crossfade, no click).
|
||||||
}, [tracks, sessionTabs]);
|
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() {
|
window.__toggleMediaExplorerRef = function() {
|
||||||
setShowMediaExplorer(function(p) {
|
setShowMediaExplorer(function(p) {
|
||||||
const next = !p;
|
const next = !p;
|
||||||
@@ -12274,6 +12380,42 @@ const App = () => {
|
|||||||
activeTracksRef.current = activeTracks;
|
activeTracksRef.current = activeTracks;
|
||||||
const sessionTabsRef = useRef(sessionTabs);
|
const sessionTabsRef = useRef(sessionTabs);
|
||||||
sessionTabsRef.current = 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 [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||||
|
|
||||||
const midiVuActivityRef = useRef({});
|
const midiVuActivityRef = useRef({});
|
||||||
@@ -12398,23 +12540,23 @@ const App = () => {
|
|||||||
}));
|
}));
|
||||||
if (hasLoadedAny) {
|
if (hasLoadedAny) {
|
||||||
setTracks(prev => {
|
setTracks(prev => {
|
||||||
const merged = [...updatedTracks];
|
// Merge by TRACK ID (not array index): if the state changed between the
|
||||||
(prev || []).forEach((pt, i) => {
|
// fetch start and now (e.g. another project opened), index-based merging
|
||||||
if (!merged[i]) merged[i] = pt;
|
// would scramble tracks and drop items into the wrong track. updatedTracks
|
||||||
else {
|
// is authoritative; patch clip buffers from prev by matching clip ids.
|
||||||
merged[i] = { ...merged[i] };
|
const prevById = new Map((prev || []).map(pt => [pt.id, pt]));
|
||||||
merged[i].clips = (pt.clips || []).map((pc, j) => {
|
return updatedTracks.map(ut => {
|
||||||
if (merged[i].clips && merged[i].clips[j] && (merged[i].clips[j].buffer || pc.buffer)) {
|
const pt = prevById.get(ut.id);
|
||||||
return { ...pc, buffer: pc.buffer || merged[i].clips[j].buffer };
|
if (!pt) return ut;
|
||||||
}
|
const ptClips = pt.clips || [];
|
||||||
if ((pc.buffer || (merged[i].clips && merged[i].clips[j] && merged[i].clips[j].buffer))) {
|
const utClips = ut.clips || [];
|
||||||
return pc;
|
const mergedClips = ptClips.map(pc => {
|
||||||
}
|
const uc = utClips.find(c => c.id === pc.id);
|
||||||
return merged[i].clips && merged[i].clips[j] ? merged[i].clips[j] : pc;
|
if (uc) return { ...pc, buffer: pc.buffer || uc.buffer };
|
||||||
});
|
return pc;
|
||||||
}
|
});
|
||||||
|
return { ...ut, clips: mergedClips };
|
||||||
});
|
});
|
||||||
return merged;
|
|
||||||
});
|
});
|
||||||
setSessionTabs(prev => prev.map(st => ({
|
setSessionTabs(prev => prev.map(st => ({
|
||||||
...st,
|
...st,
|
||||||
@@ -15353,6 +15495,8 @@ const App = () => {
|
|||||||
pannerNode,
|
pannerNode,
|
||||||
source
|
source
|
||||||
};
|
};
|
||||||
|
// Realtime mute/solo for the newly created playback chain.
|
||||||
|
if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(st.trackId, null);
|
||||||
startOffsetTimeRef.current = offsetWallTime;
|
startOffsetTimeRef.current = offsetWallTime;
|
||||||
startBufferOffsetRef.current = offsetBuffer;
|
startBufferOffsetRef.current = offsetBuffer;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
@@ -15627,11 +15771,12 @@ const App = () => {
|
|||||||
const analyserNode = context.createAnalyser();
|
const analyserNode = context.createAnalyser();
|
||||||
analyserNode.fftSize = 256;
|
analyserNode.fftSize = 256;
|
||||||
pannerNode.connect(analyserNode);
|
pannerNode.connect(analyserNode);
|
||||||
// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
|
// Dual mastering route: routeGain -> mastering chain (normal, post-FX),
|
||||||
// dry bus (bypass). Live-toggled via setMasteringRoute(node.route, ...).
|
// 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);
|
const route = createMasteringRoute(context, track, masterBus);
|
||||||
analyserNode.connect(route.routeGain);
|
analyserNode.connect(route.routeGain);
|
||||||
analyserNode.connect(route.dryGain);
|
gainNode.connect(route.dryGain);
|
||||||
|
|
||||||
let fxStopFn;
|
let fxStopFn;
|
||||||
if (track.fxType === 'chorus') {
|
if (track.fxType === 'chorus') {
|
||||||
@@ -15648,6 +15793,10 @@ const App = () => {
|
|||||||
gainNode.connect(pannerNode);
|
gainNode.connect(pannerNode);
|
||||||
}
|
}
|
||||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
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;
|
activeTrackNodesRef.current[track.id] = node;
|
||||||
}
|
}
|
||||||
return node.gainNode;
|
return node.gainNode;
|
||||||
@@ -16912,6 +17061,8 @@ const App = () => {
|
|||||||
isDuplicate: false
|
isDuplicate: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handleClipDragStartRef = useRef(null);
|
||||||
|
handleClipDragStartRef.current = handleClipDragStart;
|
||||||
const stretchedClipRef = useRef(null);
|
const stretchedClipRef = useRef(null);
|
||||||
stretchedClipRef.current = stretchedClip;
|
stretchedClipRef.current = stretchedClip;
|
||||||
const handleClipStretchStart = (trackId, clipId, clickTime) => {
|
const handleClipStretchStart = (trackId, clipId, clickTime) => {
|
||||||
@@ -17222,7 +17373,14 @@ const App = () => {
|
|||||||
} else {
|
} else {
|
||||||
ids.add(itemId);
|
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 ──
|
// ── Sweep Select ──
|
||||||
@@ -17321,8 +17479,8 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null;
|
var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null;
|
||||||
var its = itemType === 'section' ? (curTrk?.sections || []) : (curTrk?.midiItems || []);
|
var its = itemType === 'section' ? (curTrk?.sections || []) : (itemType === 'clip' ? (curTrk?.clips || []) : (curTrk?.midiItems || []));
|
||||||
var it = its.find(function(x) { return x.id === itemId; });
|
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 origPos = it ? (itemType === 'section' ? it.start : it.startTime) : 0;
|
||||||
var beforeSnap = captureAllTracksSnapshot();
|
var beforeSnap = captureAllTracksSnapshot();
|
||||||
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos }, beforeSnap });
|
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos }, beforeSnap });
|
||||||
@@ -17550,7 +17708,7 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
}, [zoom, activeTab, sessionTabs]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = e => {
|
const handleMouseMove = e => {
|
||||||
var pd = pendingDragRef.current;
|
var pd = pendingDragRef.current;
|
||||||
@@ -17559,7 +17717,12 @@ const App = () => {
|
|||||||
if (Math.abs(dx) > 5) {
|
if (Math.abs(dx) > 5) {
|
||||||
var pdSnap = pendingDragRef.current;
|
var pdSnap = pendingDragRef.current;
|
||||||
pendingDragRef.current = null;
|
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);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
@@ -22944,6 +23107,7 @@ const App = () => {
|
|||||||
onDeselectItem: handleDeselectItem,
|
onDeselectItem: handleDeselectItem,
|
||||||
onAddToSelection: handleAddToSelection,
|
onAddToSelection: handleAddToSelection,
|
||||||
onSetPendingDrag: handleSetPendingDrag,
|
onSetPendingDrag: handleSetPendingDrag,
|
||||||
|
onSetPendingDragMove: handleSetPendingDragMove,
|
||||||
onContextMenu: handleContextMenu,
|
onContextMenu: handleContextMenu,
|
||||||
onClipDragStart: handleClipDragStart,
|
onClipDragStart: handleClipDragStart,
|
||||||
onClipStretchStart: handleClipStretchStart,
|
onClipStretchStart: handleClipStretchStart,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608031800" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608032500" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regression tests for legacy project upgrade (items must stay on their tracks
|
||||||
|
with correct bar positions)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.api.v1.projects import upgrade_project_json_if_needed
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_project():
|
||||||
|
return {
|
||||||
|
"id": "legacy_1",
|
||||||
|
"name": "Legacy",
|
||||||
|
"bpm": 120.0, # 1 bar = 2.0s
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 1",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": "abc.wav",
|
||||||
|
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
|
||||||
|
"midiItems": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 2",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": None,
|
||||||
|
"clips": [],
|
||||||
|
"midiItems": [
|
||||||
|
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
|
||||||
|
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_keeps_items_on_their_tracks():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
assert len(tracks) == 2
|
||||||
|
t1_items = tracks[0]["items"]
|
||||||
|
t2_items = tracks[1]["items"]
|
||||||
|
# Items must NOT be merged into the first track
|
||||||
|
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
|
||||||
|
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
|
||||||
|
assert t1_items[0]["id"] == "c1"
|
||||||
|
assert t2_items[0]["id"] == "m1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_uses_bpm_based_seconds_per_bar():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
|
||||||
|
assert tracks[0]["items"][0]["start_bar"] == 1.0
|
||||||
|
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
|
||||||
|
assert tracks[1]["items"][0]["start_bar"] == 2.0
|
||||||
|
assert tracks[1]["items"][0]["duration_bars"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_skips_new_format():
|
||||||
|
data = {"main_session": {"tracks": []}}
|
||||||
|
assert upgrade_project_json_if_needed(data) is data
|
||||||
@@ -1,3 +1,45 @@
|
|||||||
|
### [2026-08-03] Task: Mute/Solo realtime cho MIDI items — dùng CC7 (channel volume) của FluidSynth
|
||||||
|
- **Tóm tắt thay đổi:** Mute/unmute/solo chưa realtime với **MIDI items** vì FluidSynth WASM render toàn bộ channel vào **1 worklet → 1 `_gainNode` chung** (`_workletNode.connect(_gainNode)`), nên gain node của track không câm được MIDI (chỉ audio clips + section sub-tracks qua track gain mới bị ảnh hưởng). Fix: mỗi track MIDI sở hữu **channel riêng** (`ensureTrackMidiChannel`, 0-15 trừ 9) → dùng **CC7 (channel volume)** của FluidSynth (`window.SonicSF.controllerChange(ch, 7, 100|0)`) — áp realtime kể cả với notes đang vang. Thêm vào `applyAllTrackMuteSolo` (nút M/S) + effect sync `[tracks, sessionTabs]` (load/undo). Khi mute → CC7=0 (notes đang phát câm ngay); unmute → CC7=100 (notes đang phát vang lại + cơ chế becameAudible→restart vẫn giữ). Audio clips/section vẫn qua track gain như cũ.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032500)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `controllerChange(ch,7,...)` ×2 trong bundle; `pytest` 86 passed.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Fix items rớt/dồn sai track khi load-save project (3 bug)
|
||||||
|
- **Tóm tắt thay đổi:** (1) **`serializeTracksList` (app.jsx)**: chuỗi `if/else-if` theo `trackType` chỉ serialize MỘT loại items → track có cả clips + midiItems/sections bị **rớt items khi save** (mất dữ liệu âm thầm, có thể gây cảm giác "items biến mất/dồn chỗ"). Sửa: serialize ĐỘC LẬP từng loại items có trên track. (2) **`upgrade_project_json_if_needed` (projects.py)**: hardcode `start_bar = startTime/4.0` + `duration_bars = 4.0` → vị trí items sai (lệch 2× ở 120bpm, càng lệch khi tempo khác). Sửa: dùng `seconds_per_bar = (60/bpm)*4` từ bpm của project. (3) **`loadAudioBuffersForTracks` (app.jsx)**: merge buffer theo **array index** → nếu state đổi giữa lúc fetch (race: mở project khác khi buffer-load cũ chưa xong) thì tracks bị xáo trộn, items rơi vào track sai. Sửa: merge theo **track ID** + clip ID.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/api/v1/projects.py`, `app/templates/index.html` (bump v=202608032400), `tests/test_project_upgrade.py` (mới)
|
||||||
|
- **Ghi chú/Test (nếu có):** verify: round-trip serialize→deserialize giữ đúng track + vị trí (đã test bằng code bundle thật); track hỗn hợp giờ ra `AUDIO_ITEM,MIDI_ITEM`; `pytest` **86 passed** (3 test mới cho upgrade). Lưu ý: deserialize/upgrade vốn đã map đúng từng track — nếu user vẫn thấy items dồn track 1 sau khi hard-refresh, cần kiểm tra dữ liệu project cụ thể (và bản bundle trình duyệt đang chạy).
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Fix unmute không phát lại khi đang play (sources bị loop-restart bỏ qua)
|
||||||
|
- **Tóm tắt thay đổi:** Khi đang play, mute → câm (OK, gain 0) nhưng unmute → không nghe lại được. Nguyên nhân: nếu có loop restart (`stopAllPlayback` + `startTrackPlayback` trong updatePlayhead), vòng lặp mới **bỏ qua track đang mute/solo** (isPlayable check) → sources của track không được tạo lại → chỉ set gain khi unmute không "hồi sinh" được nguồn đã không tồn tại. Fix trong `applyAllTrackMuteSolo`: theo dõi `trackAudibleRef` (audibility từng track), khi phát hiện chuyển tiếp **inaudible → audible** (unmute / tắt solo) và `isPlaying` + không đang RECORDING → `stopAllPlayback()` + `startTrackPlayback(currentTime)` re-schedule lại từ playhead hiện tại (đúng cơ chế toggleTrackSoloEvaluate đã dùng cho solo). Mute thuần (audible→inaudible) vẫn chỉ set gain (tức thì, không gián đoạn track khác). Effect sync `[tracks, sessionTabs]` giờ cũng cập nhật `trackAudibleRef` để nhất quán khi load/undo.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032300)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Mute/Solo realtime — tác dụng ngay lên items đang phát của track
|
||||||
|
- **Tóm tắt thay đổi:** Nút M/S ở cả MixerStrip (mixer F7) và TrackStripConsole (track strip) giờ tác dụng **realtime**: toggle mute/solo lập tức set gain của track node đang phát (crossfade 20ms, không click). Cơ chế: `computeTrackAudibleGain(list, track)` (mute luôn tắt; nếu có bất kỳ solo → chỉ track solo nghe được; ngược lại theo volumeDb), `setTrackNodeGain(node, gain)` áp vào `gainNode` của track node. `window.__applyTrackMuteSolo(trackId, patch)` được gọi từ: (1) nút M/S của cả 2 strip (patch state + áp ngay), (2) `getOrCreateTrackNode` khi tạo node (items của track muted/soloed bắt đầu đúng trạng thái), (3) `startSubTabPlayback` cho chain mới, (4) effect sync `[tracks, sessionTabs]` với signature `muted|solo|volumeDb` (chỉ re-apply khi thay đổi — phủ load project/undo/section tab). Vì audio clip `source.connect(gainNode)` và MIDI note (đường oscillator fallback) đều qua track gainNode → mute/solo có tác dụng lên toàn bộ items của track.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032200)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed. Giới hạn: MIDI qua FluidSynth WASM render chung 1 worklet → không tách theo track (cùng giới hạn với bypass); đường oscillator fallback thì có tác dụng.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Đồng bộ hành vi click+drag item (clip = move như section/MIDI; Alt+click+drag = quét chọn)
|
||||||
|
- **Tóm tắt thay đổi:** (1) **Thống nhất**: click + drag trên MỌI item (section/MIDI/audio clip) = **di chuyển**; trước đây audio clip click+drag = quét chọn vùng (gây cảm giác "không đáp ứng"). (2) Hành động cũ của audio clip chuyển sang **Alt+click+drag = quét chọn duration** (gọi `onTrackLaneMouseDown` → local sweep select); Alt+right-edge vẫn = time-stretch. (3) **Fix "thỉnh thoảng không đáp ứng"**: mọi item giờ dùng cơ chế **pending drag có threshold 5px** — click thuần chỉ chọn item (không tạo undo/toast, không vô tình di chuyển do rung chuột), di chuột >5px mới bắt đầu drag. Cơ chế: `handleSetPendingDragMove` (mới) lưu `pendingDragRef` với `duplicate:false`; effect pending-drag route: clip đơn → `handleClipDragStartRef` (clip machinery), còn lại → `handleSectionItemDragStartRef` (multiIds đã hỗ trợ clip). `handleSetPendingDrag` (Ctrl+click copy) giờ lưu `duplicate:true`. Sửa lookup clip trong nhánh non-duplicate của `handleSectionItemDragStart`.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032100)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `duplicate:true`/`!pdSnap.duplicate` trong bundle. `pytest` 83 passed. Grab tool (kéo nhanh) giữ nguyên.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Bypass trên nút Routing Matrix (track strip TCP) + bypass bỏ cả track FX
|
||||||
|
- **Tóm tắt thay đổi:** (1) Nút **Routing Matrix** trong `TrackStripConsole` (TCP strip, cạnh M/S) giờ là nút **Bypass** — tooltip "Bypass: track KHÔNG qua FX + mastering ở Main out", active style xanh khi bật. (2) Thay đổi điểm lấy tín hiệu dry: trong `getOrCreateTrackNode`, `dryGain` giờ tap từ **`gainNode` (PRE-FX)** thay vì `analyserNode` (post-FX) → khi bypass, channel bỏ qua **cả track FX (chorus/reverb) lẫn mastering chain** ở Main out; đường routeGain giữ nguyên (post-FX → mastering) khi không bypass. (3) Xác nhận Mixer Panel đã ở phím **F7** (code sẵn: F7 → `__toggleMixerRef`).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032000)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, không còn "Routing Matrix", `pytest` 83 passed.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Fix TDZ "Cannot access 'sessionTabs' before initialization" sau khi thêm nút Bypass
|
||||||
|
- **Tóm tắt thay đổi:** `useEffect` sync `trackMasteringBypassMap` đặt ở ~11786 nhưng dependency array `[tracks, sessionTabs]` được đánh giá ngay tại chỗ gọi — trước khi `sessionTabs` khai báo (12239) → `ReferenceError: Cannot access 'sessionTabs' before initialization` khi chạy app. Fix: di chuyển useEffect xuống sau khối khai báo `subTabs`/`sessionTabs`/`sessionTabsRef` (TDZ-safe). `window.__setTrackMasteringBypass` an toàn vì chỉ truy cập `activeTrackNodesRef` bên trong body hàm (lúc click).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031900)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, verify trong bundle: effect @453168 sau sessionTabs decl @451814. `pytest` 83 passed. Người dùng chạy `npm run build` trên workspace là được.
|
||||||
|
---
|
||||||
|
|
||||||
### [2026-08-03] Task: Nút Bypass Mastering cho từng track strip trong Mixer Panel
|
### [2026-08-03] Task: Nút Bypass Mastering cho từng track strip trong Mixer Panel
|
||||||
- **Tóm tắt thay đổi:** Thêm nút **B** (Bypass) trong mỗi track strip của Mixer Panel (cạnh M/S). Khi bật ON: âm thanh track đi qua **dry bus mới** (`masterBus.dryInput → dryOutput → output`) — **bỏ qua toàn bộ chuỗi mastering** (EQ / Imager / Maximizer) nhưng vẫn qua master volume + metering ở Main out. Cơ chế: `createMasteringRoute()` tạo 2 đường gain bù nhau (routeGain → `masterBus.input` qua mastering, dryGain → `dryInput`); `setMasteringRoute()` crossfade 20ms khi toggle (không click). Áp dụng tại: `getOrCreateTrackNode` (node track chính, toggle live qua `node.route`), `startSubTabPlayback` (clip playback). Map trạng thái `trackMasteringBypassMap` sync từ tracks state qua useEffect; `window.__setTrackMasteringBypass` toggle ngay cho track đang phát. Lưu/đọc project: `mastering_bypass` trong serialize/deserialize (schema không chặn additionalProperties nên không cần sửa).
|
- **Tóm tắt thay đổi:** Thêm nút **B** (Bypass) trong mỗi track strip của Mixer Panel (cạnh M/S). Khi bật ON: âm thanh track đi qua **dry bus mới** (`masterBus.dryInput → dryOutput → output`) — **bỏ qua toàn bộ chuỗi mastering** (EQ / Imager / Maximizer) nhưng vẫn qua master volume + metering ở Main out. Cơ chế: `createMasteringRoute()` tạo 2 đường gain bù nhau (routeGain → `masterBus.input` qua mastering, dryGain → `dryInput`); `setMasteringRoute()` crossfade 20ms khi toggle (không click). Áp dụng tại: `getOrCreateTrackNode` (node track chính, toggle live qua `node.route`), `startSubTabPlayback` (clip playback). Map trạng thái `trackMasteringBypassMap` sync từ tracks state qua useEffect; `window.__setTrackMasteringBypass` toggle ngay cho track đang phát. Lưu/đọc project: `mastering_bypass` trong serialize/deserialize (schema không chặn additionalProperties nên không cần sửa).
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031800)
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031800)
|
||||||
|
|||||||
Reference in New Issue
Block a user