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:
2026-08-03 17:22:19 +07:00
parent a9da813cb1
commit 8fc1c2641b
6 changed files with 418 additions and 91 deletions
+10 -5
View File
@@ -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", [])
} }
}) })
+208 -44
View File
@@ -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,11 +831,11 @@ 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", {
@@ -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,11 +2203,10 @@ 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();
@@ -2185,10 +2216,36 @@ const WaveformLane = ({
onAddToSelection(clipCanonicalId); onAddToSelection(clipCanonicalId);
} }
if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot); if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot);
} else { return;
// Alt+Click: move immediately
if (onClipDragStart) onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, false);
} }
// 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 inaudibleaudible
// 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 || [];
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 pc;
}
return merged[i].clips && merged[i].clips[j] ? merged[i].clips[j] : 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
+1 -1
View File
@@ -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 {
+68
View File
@@ -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
+42
View File
@@ -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)