IMPROVE: thêm nút bypass cho midi items và audio items

This commit is contained in:
2026-08-03 21:51:37 +07:00
parent e5b4321a55
commit 1af2119444
4 changed files with 294 additions and 65 deletions
+195 -37
View File
@@ -67,11 +67,21 @@ let masterBus = null; // { input, compressor, analyser, output, masteringActive,
// Per-track mastering-bypass state (trackId -> bool), kept in sync with the // Per-track mastering-bypass state (trackId -> bool), kept in sync with the
// tracks state so ANY audio path can route without holding the track object. // tracks state so ANY audio path can route without holding the track object.
const trackMasteringBypassMap = {}; const trackMasteringBypassMap = {};
const trackAudioBypassMap = {};
const trackMidiBypassMap = {};
// Build the dual routing for one track: routeGain -> mastering chain (normal), // Build the dual routing for one track: routeGain -> mastering chain (normal),
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values. // dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
function createMasteringRoute(ctx, track, bus) { function createMasteringRoute(ctx, track, bus) {
const bypass = !!(track && track.masteringBypass); // Prefer the live bypass map (synced from the tracks state on every render),
// falling back to the track object this guarantees the A-button toggle is
// picked up even if a stale track object is passed in.
let bypass = false;
if (track && track.id && trackAudioBypassMap[track.id] !== undefined) {
bypass = !!trackAudioBypassMap[track.id];
} else {
bypass = !!(track && (track.audioBypass ?? track.masteringBypass));
}
const routeGain = ctx.createGain(); const routeGain = ctx.createGain();
const dryGain = ctx.createGain(); const dryGain = ctx.createGain();
const masterDest = bus ? bus.input : ctx.destination; const masterDest = bus ? bus.input : ctx.destination;
@@ -80,18 +90,30 @@ function createMasteringRoute(ctx, track, bus) {
dryGain.gain.value = bypass ? 1 : 0; dryGain.gain.value = bypass ? 1 : 0;
routeGain.connect(masterDest); routeGain.connect(masterDest);
dryGain.connect(dryDest); dryGain.connect(dryDest);
return { routeGain, dryGain }; const routeObj = { routeGain, dryGain };
routeObj._trackId = track && track.id;
routeObj._bypass = bypass;
return routeObj;
} }
// Live-toggle a route with a short crossfade (click-free). // Live-toggle a route. HARD switch: cancel any pending automation and assign
// .value directly (instant, cannot be delayed by the automation queue).
function setMasteringRoute(route, bypass) { function setMasteringRoute(route, bypass) {
if (!route || !audioCtx) return; if (!route) return;
const t = audioCtx.currentTime;
const on = !!bypass; const on = !!bypass;
route.routeGain.gain.cancelScheduledValues(t); try {
route.dryGain.gain.cancelScheduledValues(t); const ctx = (typeof getAudioContext === 'function') ? getAudioContext() : null;
route.routeGain.gain.setTargetAtTime(on ? 0 : 1, t, 0.02); if (!ctx) return;
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02); const t = ctx.currentTime;
route.routeGain.gain.cancelScheduledValues(t);
route.dryGain.gain.cancelScheduledValues(t);
route.routeGain.gain.value = on ? 0 : 1;
route.dryGain.gain.value = on ? 1 : 0;
route._bypass = on;
console.log('[Bypass] track', route._trackId, 'audioBypass=' + on, '→', on ? 'DRY BUS (bỏ mastering + bỏ track FX)' : 'MASTERING CHAIN (qua FX + mastering)');
} catch (e) {
console.warn('setMasteringRoute error:', e);
}
} }
// Realtime mute/solo: audible linear gain for a track given the full track list // Realtime mute/solo: audible linear gain for a track given the full track list
@@ -1088,7 +1110,8 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
const dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB'; const dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB';
const isMuted = track.muted; const isMuted = track.muted;
const isSoloed = track.solo; const isSoloed = track.solo;
const isBypassed = track.masteringBypass; const isAudioBypassed = !!track.audioBypass;
const isMidiBypassed = !!track.midiBypass;
const vol = track.volumeDb != null ? track.volumeDb : 0; const vol = track.volumeDb != null ? track.volumeDb : 0;
var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100)); var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100));
var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e'; var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e';
@@ -1114,14 +1137,25 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
}, "S"), React.createElement("button", { }, "S"), React.createElement("button", {
onClick: e => { onClick: e => {
e.stopPropagation(); e.stopPropagation();
const next = !track.masteringBypass; const next = !(track.audioBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next }); if (onUpdateTrack) onUpdateTrack(track.id, { audioBypass: next });
// Live audio re-route (applies immediately to playing tracks). // Live audio re-route (applies immediately to playing tracks).
if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next); if (window.__setTrackBypass) window.__setTrackBypass(track.id, 'audio', next);
else if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
}, },
title: "Bypass Mastering: bật thì track KHÔNG qua EQ/Imager/Maximizer ở Main out", title: "Bypass Audio (clips + sections): XÁM = bypass đang bật (bỏ FX + mastering), SÁNG XANH = xử lý bình thường",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isBypassed ? 'bg-sky-400 text-black border-sky-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 " + (isAudioBypassed ? 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100' : 'bg-sky-400 text-black border-sky-300')
}, "B")), }, "A"), React.createElement("button", {
onClick: e => {
e.stopPropagation();
const next = !(track.midiBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { midiBypass: next });
if (window.__setTrackBypass) window.__setTrackBypass(track.id, 'midi', next);
else if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
},
title: "Bypass MIDI (soundfont): XÁM = bypass đang bật (bỏ FX + mastering), SÁNG TÍM = xử lý bình thường",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMidiBypassed ? 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100' : 'bg-fuchsia-400 text-black border-fuchsia-300')
}, "\u266A")),
React.createElement("div", { React.createElement("div", {
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0" className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
}, React.createElement("div", { }, React.createElement("div", {
@@ -1472,7 +1506,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 isBypassed = !!(track.audioBypass ?? 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';
@@ -1618,14 +1652,26 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
React.createElement("button", { React.createElement("button", {
onClick: function(e) { onClick: function(e) {
e.stopPropagation(); e.stopPropagation();
var next = !track.masteringBypass; var next = !(track.audioBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next }); if (onUpdateTrack) onUpdateTrack(track.id, { audioBypass: next });
// Live re-route: bypassed channel skips FX + mastering chain at Main out. // Live re-route: bypassed channel skips FX + mastering chain at Main out.
if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next); if (window.__setTrackBypass) window.__setTrackBypass(track.id, 'audio', next);
else 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" : ""), className: "btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[9px] " + (track.audioBypass ? " text-slate-500" : " text-sky-300 bg-sky-500/30 border-sky-400/70"),
title: "Bypass: track KHÔNG qua FX + mastering ở Main out" title: "Bypass Audio (clips + sections): XÁM = bypass đang bật (bỏ FX + mastering), SÁNG XANH = xử lý bình thường"
}, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })), }, "A"),
React.createElement("button", {
onClick: function(e) {
e.stopPropagation();
var next = !(track.midiBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { midiBypass: next });
if (window.__setTrackBypass) window.__setTrackBypass(track.id, 'midi', next);
else if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
},
className: "btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[10px] " + (track.midiBypass ? " text-slate-500" : " text-fuchsia-300 bg-fuchsia-500/30 border-fuchsia-400/70"),
title: "Bypass MIDI (soundfont): XÁM = bypass đang bật (bỏ FX + mastering), SÁNG TÍM = xử lý bình thường"
}, "\u266A"),
React.createElement("button", { React.createElement("button", {
onClick: function() { if (window.__openFxRack) window.__openFxRack(track.id, track.name); }, onClick: function() { if (window.__openFxRack) window.__openFxRack(track.id, track.name); },
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]" + ((track.fxChain || []).length > 0 ? " text-cyan-400" : ""), className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]" + ((track.fxChain || []).length > 0 ? " text-cyan-400" : ""),
@@ -8388,7 +8434,9 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
pan: t.pan || 0.0, pan: t.pan || 0.0,
mute: t.muted || false, mute: t.muted || false,
solo: t.solo || false, solo: t.solo || false,
mastering_bypass: t.masteringBypass || false, mastering_bypass: t.audioBypass || false,
audio_bypass: t.audioBypass || false,
midi_bypass: t.midiBypass || false,
instrument_id: t.instrumentId || null, instrument_id: t.instrumentId || null,
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null, instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
instrument_name: t.instrumentName || null, instrument_name: t.instrumentName || null,
@@ -8465,7 +8513,9 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
pan: t.pan || 0.0, pan: t.pan || 0.0,
muted: t.mute || false, muted: t.mute || false,
solo: t.solo || false, solo: t.solo || false,
masteringBypass: t.mastering_bypass || false, masteringBypass: (t.audio_bypass !== undefined ? t.audio_bypass : (t.mastering_bypass || false)),
audioBypass: (t.audio_bypass !== undefined ? t.audio_bypass : (t.mastering_bypass || false)),
midiBypass: (t.midi_bypass !== undefined ? t.midi_bypass : (t.mastering_bypass || false)),
color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'), color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'),
startTime: t.start_time || 0, startTime: t.start_time || 0,
height: t.height || 140, height: t.height || 140,
@@ -12893,12 +12943,34 @@ const App = () => {
}); });
}; };
// Live per-track mastering bypass: updates the routing map + re-routes any // Live per-track bypass (split into two buttons per user request):
// active track node immediately (called from MixerStrip's B button). // - 'audio': bypass FX+mastering for AUDIO items (audio clips + section items)
// - 'midi' : bypass FX+mastering for MIDI items (soundfont instrument)
window.__setTrackBypass = function(trackId, which, on) {
if (which === 'audio') {
trackAudioBypassMap[trackId] = !!on;
trackMasteringBypassMap[trackId] = !!on;
// Live re-route the main track node AND every section sub-node of this
// track (keys "<trackId>_sub_<subId>") so section items bypass instantly.
Object.keys(activeTrackNodesRef.current).forEach(function(k) {
if (k === trackId || k.indexOf(trackId + '_sub_') === 0) {
const n = activeTrackNodesRef.current[k];
if (n && n.route) setMasteringRoute(n.route, !!on);
}
});
} else if (which === 'midi') {
trackMidiBypassMap[trackId] = !!on;
try { if (updateSfRoutingRef.current) updateSfRoutingRef.current(); } catch (e) { }
}
};
// Legacy single-button bypass (kept for backward compatibility): sets both.
window.__setTrackMasteringBypass = function(trackId, bypass) { window.__setTrackMasteringBypass = function(trackId, bypass) {
trackAudioBypassMap[trackId] = !!bypass;
trackMasteringBypassMap[trackId] = !!bypass; trackMasteringBypassMap[trackId] = !!bypass;
trackMidiBypassMap[trackId] = !!bypass;
const node = activeTrackNodesRef.current[trackId]; const node = activeTrackNodesRef.current[trackId];
if (node && node.route) setMasteringRoute(node.route, !!bypass); if (node && node.route) setMasteringRoute(node.route, !!bypass);
try { if (updateSfRoutingRef.current) updateSfRoutingRef.current(); } catch (e) { }
}; };
// Realtime mute/solo: applies the (patched) mute/solo state to every active // Realtime mute/solo: applies the (patched) mute/solo state to every active
@@ -12913,7 +12985,11 @@ const App = () => {
const audible = computeTrackAudibleGain(effective, t) > 0; const audible = computeTrackAudibleGain(effective, t) > 0;
const wasAudible = trackAudibleRef.current[t.id] !== false; const wasAudible = trackAudibleRef.current[t.id] !== false;
const node = activeTrackNodesRef.current[t.id]; const node = activeTrackNodesRef.current[t.id];
if (node) setTrackNodeGain(node, computeTrackAudibleGain(effective, t)); const audibleGain = computeTrackAudibleGain(effective, t);
if (node) setTrackNodeGain(node, audibleGain);
if (node && node.sfEntry) {
try { node.sfEntry.gain.setTargetAtTime(audibleGain, getAudioContext().currentTime, 0.02); } catch (e) {}
}
// MIDI items: FluidSynth mixes ALL channels into ONE shared gain node, so // MIDI items: FluidSynth mixes ALL channels into ONE shared gain node, so
// the per-track gain cannot silence them. Every MIDI track owns a // the per-track gain cannot silence them. Every MIDI track owns a
// dedicated channel (ensureTrackMidiChannel) mute/solo via CC7 (channel // dedicated channel (ensureTrackMidiChannel) mute/solo via CC7 (channel
@@ -13450,7 +13526,11 @@ const App = () => {
// sessionTabs declarations (TDZ-safe). // sessionTabs declarations (TDZ-safe).
useEffect(() => { useEffect(() => {
const all = [...(tracks || []), ...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), [])]; const all = [...(tracks || []), ...(sessionTabs || []).reduce((acc, s) => acc.concat(s.tracks || []), [])];
all.forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }); all.forEach(t => {
trackMasteringBypassMap[t.id] = !!t.audioBypass;
trackAudioBypassMap[t.id] = !!t.audioBypass;
trackMidiBypassMap[t.id] = !!t.midiBypass;
});
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : all; const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : all;
list.forEach(t => { list.forEach(t => {
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0); const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
@@ -13458,7 +13538,11 @@ const App = () => {
trackMuteSoloSigRef.current[t.id] = sig; trackMuteSoloSigRef.current[t.id] = sig;
const audible = computeTrackAudibleGain(list, t) > 0; const audible = computeTrackAudibleGain(list, t) > 0;
const node = activeTrackNodesRef.current[t.id]; const node = activeTrackNodesRef.current[t.id];
if (node) setTrackNodeGain(node, computeTrackAudibleGain(list, t)); const audibleGain = computeTrackAudibleGain(list, t);
if (node) setTrackNodeGain(node, audibleGain);
if (node && node.sfEntry) {
try { node.sfEntry.gain.setTargetAtTime(audibleGain, getAudioContext().currentTime, 0.02); } catch (e) {}
}
// MIDI tracks: mirror the gain decision onto the channel CC7 volume so // MIDI tracks: mirror the gain decision onto the channel CC7 volume so
// FluidSynth-rendered notes respect mute/solo too. // FluidSynth-rendered notes respect mute/solo too.
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') { if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
@@ -16558,7 +16642,7 @@ const App = () => {
volumeGainNode.connect(pannerNode); volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode); pannerNode.connect(fadeGainNode);
// Route through mastering chain unless this track has mastering bypass ON. // Route through mastering chain unless this track has mastering bypass ON.
const route = createMasteringRoute(context, { masteringBypass: !!trackMasteringBypassMap[st.trackId] }, masterBus); const route = createMasteringRoute(context, { masteringBypass: !!trackAudioBypassMap[st.trackId] }, masterBus);
fadeGainNode.connect(route.routeGain); fadeGainNode.connect(route.routeGain);
fadeGainNode.connect(route.dryGain); fadeGainNode.connect(route.dryGain);
source.start(context.currentTime, offsetBuffer); source.start(context.currentTime, offsetBuffer);
@@ -16893,12 +16977,50 @@ const App = () => {
} else { } else {
fxLegacyIn.connect(pannerNode); fxLegacyIn.connect(pannerNode);
} }
node = { gainNode, pannerNode, fxStopFn, analyserNode, route, fxEntry, fxLegacyIn, scopeAnalyserL, scopeAnalyserR }; // Independent MIDI/soundfont FX path (user request): SF audio flows
// gainNode sfEntry [own module instances] sfOut pan master bus,
// fully separate from the audio-clips chain so the A (audio) bypass
// NEVER affects the soundfont instrument, and the bypass never touches
// audio clips/sections.
let sfEntry = null, sfOut = null, sfPan = null, sfAnalyser = null;
if ((track.midiItems && track.midiItems.length > 0)) {
sfEntry = context.createGain();
sfOut = context.createGain();
sfPan = context.createStereoPanner();
sfPan.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
sfAnalyser = context.createAnalyser();
sfAnalyser.fftSize = 2048;
// NOTE: sfEntry is NOT fed from gainNode the FluidSynth output is
// routed into sfEntry directly by updateSfRouting(). Connecting
// gainNode sfEntry here would LEAK every audio clip/section into the
// SF chain masterBus.input mastering even when bypassed (double
// audio + still processed). sfEntry.gain mirrors the track's audible
// gain (volume/mute/solo) instead.
sfEntry.gain.value = 1;
sfOut.connect(sfPan);
sfPan.connect(masterBus.input);
sfOut.connect(sfAnalyser);
const sfMods = fxChain.filter(m => m && m.active !== false).map(m => {
try { return createTrackFxModule(m.type || m, context, m.params); } catch (e) { return null; }
}).filter(Boolean);
let sfTail = sfEntry;
sfMods.forEach(mod => {
sfTail.connect(mod.input);
sfTail = mod.output;
});
sfTail.connect(sfOut);
}
node = { gainNode, pannerNode, fxStopFn, analyserNode, route, fxEntry, fxLegacyIn, scopeAnalyserL, scopeAnalyserR, sfEntry, sfOut, sfPan, sfAnalyser };
// Realtime mute/solo: apply the track's current mute/solo/volume state to // Realtime mute/solo: apply the track's current mute/solo/volume state to
// the fresh node so items of muted/soloed tracks start correctly. // the fresh node so items of muted/soloed tracks start correctly.
const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track]; const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track];
setTrackNodeGain(node, computeTrackAudibleGain(trackList, track)); const nodeAudibleGain = computeTrackAudibleGain(trackList, track);
setTrackNodeGain(node, nodeAudibleGain);
if (node.sfEntry) {
try { node.sfEntry.gain.setTargetAtTime(nodeAudibleGain, context.currentTime, 0.02); } catch (e) {}
}
activeTrackNodesRef.current[track.id] = node; activeTrackNodesRef.current[track.id] = node;
console.log('[Bypass] node created track', track.id, 'initial audioBypass=', !!trackAudioBypassMap[track.id], 'routeGain=', node.route.routeGain.gain.value, 'dryGain=', node.route.dryGain.gain.value);
updateSfRouting(); updateSfRouting();
} }
return node.gainNode; return node.gainNode;
@@ -16924,6 +17046,22 @@ const App = () => {
tail = mod.output; tail = mod.output;
}); });
tail.connect(node.fxLegacyIn); tail.connect(node.fxLegacyIn);
// Rebuild the independent SF chain too (same modules, separate instances)
if (node.sfEntry && node.sfOut) {
node.sfEntry.disconnect();
const sfMods = (track.fxChain || []).filter(m => m && m.active !== false).map(m => {
try { return createTrackFxModule(m.type || m, getAudioContext(), m.params); } catch (e) { return null; }
}).filter(Boolean);
let sfTail = node.sfEntry;
sfMods.forEach(mod => {
sfTail.connect(mod.input);
sfTail = mod.output;
});
sfTail.connect(node.sfOut);
}
if (node.sfPan && getAudioContext()) {
node.sfPan.pan.setTargetAtTime((track.pan ?? 0) / 100, getAudioContext().currentTime, 0.02);
}
} catch (e) { } catch (e) {
console.warn('rebuildTrackFxGraph error:', e); console.warn('rebuildTrackFxGraph error:', e);
} }
@@ -16942,7 +17080,20 @@ const App = () => {
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks; const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0); const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0);
if (midiAudible.length === 1) { if (midiAudible.length === 1) {
const node = activeTrackNodesRef.current[midiAudible[0].id]; const t = midiAudible[0];
const node = activeTrackNodesRef.current[t.id];
// MIDI bypass ON: route the soundfont straight to the dry bus (skips
// the track FX chain AND the mastering chain at Main out), exactly like
// the audio-group bypass CC7 keeps mute/solo working.
if (trackMidiBypassMap[t.id] && masterBus && masterBus.dryInput && window.SonicSF && window.SonicSF.setOutputDestination) {
window.SonicSF.setOutputDestination(masterBus.dryInput);
return;
}
// Independent SF FX path unaffected by the audio (A) bypass toggle.
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
window.SonicSF.setOutputDestination(node.sfEntry);
return;
}
if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) { if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) {
window.SonicSF.setOutputDestination(node.gainNode); window.SonicSF.setOutputDestination(node.gainNode);
return; return;
@@ -16960,7 +17111,10 @@ const App = () => {
window.__updateSfRouting = updateSfRouting; window.__updateSfRouting = updateSfRouting;
window.__getTrackScopeAnalysers = (trackId) => { window.__getTrackScopeAnalysers = (trackId) => {
const node = activeTrackNodesRef.current[trackId]; const node = activeTrackNodesRef.current[trackId];
if (!node || !node.scopeAnalyserL || !node.scopeAnalyserR) return null; if (!node) return null;
// MIDI tracks: show the soundfont's own analyser (post-FX).
if (node.sfAnalyser) return { L: node.sfAnalyser, R: node.sfAnalyser, sr: getAudioContext().sampleRate };
if (!node.scopeAnalyserL || !node.scopeAnalyserR) return null;
return { L: node.scopeAnalyserL, R: node.scopeAnalyserR, sr: getAudioContext().sampleRate }; return { L: node.scopeAnalyserL, R: node.scopeAnalyserR, sr: getAudioContext().sampleRate };
}; };
const getOrCreateSubTrackNode = (track, subTrack, context) => { const getOrCreateSubTrackNode = (track, subTrack, context) => {
@@ -19544,7 +19698,7 @@ const App = () => {
id: 'midi_track_' + now + '_' + idx, id: 'midi_track_' + now + '_' + idx,
name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')), name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')),
buffer: null, startTime: 0, volumeDb: 0, pan: 0, buffer: null, startTime: 0, volumeDb: 0, pan: 0,
muted: false, solo: false, masteringBypass: false, color: colors[idx % colors.length], muted: false, solo: false, masteringBypass: false, audioBypass: false, midiBypass: false, color: colors[idx % colors.length],
markers: [], serverFileId: null, clips: [], sections: [], markers: [], serverFileId: null, clips: [], sections: [],
midiItems: [midiItem], midiItems: [midiItem],
isArmed: false, monitoringEnabled: true, isArmed: false, monitoringEnabled: true,
@@ -19607,6 +19761,8 @@ const App = () => {
muted: false, muted: false,
solo: false, solo: false,
masteringBypass: false, masteringBypass: false,
audioBypass: false,
midiBypass: false,
color: selectColor, color: selectColor,
markers: [], markers: [],
serverFileId: null, serverFileId: null,
@@ -19795,6 +19951,8 @@ const App = () => {
muted: false, muted: false,
solo: false, solo: false,
masteringBypass: false, masteringBypass: false,
audioBypass: false,
midiBypass: false,
color: selectColor, color: selectColor,
markers: [], markers: [],
serverFileId: null, serverFileId: null,
@@ -23026,7 +23184,7 @@ const App = () => {
// selection exists yet. Previously this always overwrote the // selection exists yet. Previously this always overwrote the
// selection with 0 (contentEnd + 2 bars). // selection with 0 (contentEnd + 2 bars).
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft) const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|| (selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart); || (selectionStart !== null && selectionEnd !== null);
if (!hasSel) { if (!hasSel) {
const bpmVal = parseInt(bpm) || 120; const bpmVal = parseInt(bpm) || 120;
const secPerBar = (60.0 / bpmVal) * 4; const secPerBar = (60.0 / bpmVal) * 4;
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=202608033500" defer></script> <script src="/static/js/app.precompiled.js?v=202608034300" 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 {
+46
View File
@@ -1,3 +1,49 @@
### [2026-08-03] Task: FIX LEAK — clip audio chảy qua sfEntry → masterBus.input → mastering (nguyên nhân "bypass vẫn qua mastering + tăng gain")
- **Tóm tắt thay đổi:** User (bundle mới nhất, log route ĐÚNG: track1 bypass routeGain=0/dryGain=1) vẫn nghe audioclip qua mastering + tăng gain quá mức. **NGUYÊN NHÂN THẬT**: patch "SF path độc lập" trước đó nối `gainNode.connect(sfEntry)` VĨNH VIỄN — clip audio ở gainNode chảy luôn vào đường SF (`sfEntry → sfModules → sfOut → sfPan → masterBus.input → mastering`) → clip nghe **2 đường cùng lúc** (dry + SF path qua mastering) dù bypass bật → tăng gain + vẫn bị mastering xử lý. FIX:
1. **XÓA `gainNode.connect(sfEntry)`** — SF output vào sfEntry TRỰC TIẾP qua `setOutputDestination(sfEntry)` (updateSfRouting), không đi qua gainNode → clip không bao giờ chảy vào SF chain.
2. **sfEntry.gain mirror audible gain** (volume/mute/solo) ở 3 nơi: getOrCreateTrackNode (khởi tạo), applyAllTrackMuteSolo, effect sync `[tracks, sessionTabs]` — SF vẫn tuân theo fader/mute/solo.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034300)
- **Ghi chú/Test (nếu có):** BUILD OK 935143 bytes, node --check OK, `pytest` 86 passed. Verify: `gainNode.connect(sfEntry)` không còn trong bundle.
---
- **Tóm tắt thay đổi:** User yêu cầu rõ: **tắt nút A (xám) = phải bypass Mastering** — semantic đã đúng (xám = audioBypass=true = dry). Củng cố thêm: (1) `setMasteringRoute` giờ gán **`.value` TRỰC TIẾP** sau `cancelScheduledValues` (hard switch, không phụ thuộc automation queue — không thể trễ/treo); (2) thêm log `[Bypass] node created track <id> initial audioBypass=... routeGain=... dryGain=...` khi tạo track node — xác minh trạng thái ban đầu của route đúng với map. Rà lại MỌI path playback (startTrackPlayback, startLocalTrackPlayback loop-selection, section sub-nodes) đều qua track route → bypass áp cho audioclip + section ✓.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034200)
- **Ghi chú/Test (nếu có):** BUILD OK 934318 bytes, node --check OK, `pytest` 86 passed. Log mới khi bấm A: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)`.
---
- **Tóm tắt thay đổi:** User dán log: `audioBypass=false routeGain.gain=0.000 dryGain.gain=1.000` — giá trị `.value` ĐẢO ngược so với cờ. Nguyên nhân: `setTargetAtTime` là automation TRỄ (timeConstant 0.02) — `.value` đọc ngay sau khi schedule vẫn hiển thị trạng thái CŨ → log gây hiểu lầm (không phải DSP sai). Fix: `setMasteringRoute` dùng `setValueAtTime` tại currentTime (áp dụng TỨC THÌ, không ramp) + log mục tiêu rõ ràng: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)` / `→ MASTERING CHAIN (qua FX + mastering)` — không còn log giá trị stale. Lưu ý user: nếu track có MIDI items và ♪ chưa bật, tiếng đàn đi qua mastering (đúng thiết kế) — chỉ audio clips/sections mới chịu nút 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=202608034100)
- **Ghi chú/Test (nếu có):** BUILD OK 934145 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log `route is not defined` do patch trước: trong `createMasteringRoute` gán `route._trackId`/`route._bypass` nhưng biến `route` KHÔNG tồn tại trong hàm (hàm trả `{routeGain, dryGain}`; `route` là tên biến ở caller). Lỗi bắn ra MỖI lần tạo track node (play). Fix: tạo `routeObj` trước rồi gán `_trackId`/`_bypass` lên đó; `setMasteringRoute` đọc `route._trackId` hợp lệ (param).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034000)
- **Ghi chú/Test (nếu có):** BUILD OK 934143 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User báo nút A vẫn đưa tín hiệu qua Mastering panel. Rà toàn bộ chuỗi: clip → gainNode → (bypass) dryGain → masterBus.dryInput → dryOutput → output — **bỏ hoàn toàn mastering modules** (wiring đã đúng từ Pha H; masterBus: dryInput.connect(dryOutput), dryOutput.connect(output); wet: input → compressor → inputAnalyser → [modules] → outputAnalyser → output). Section items qua sub-node → parent gainNode → cùng route ✓. Củng cố: (1) `setMasteringRoute` dùng `getAudioContext()` (loại bỏ nguy cơ return sớm khi audioCtx null) + lưu `route._trackId/_bypass`; (2) **console.log `[Bypass] track <id> audioBypass=... routeGain.gain=... dryGain.gain=... → dry bus (bỏ mastering)` mỗi lần toggle** — user mở DevTools console để xác nhận đường tín hiệu thực tế. **Lưu ý: MIDI items (♪ chưa bật) ĐI QUA mastering** (masterBus.input → modules) — đúng thiết kế tách A/♪; nếu user test track có MIDI và thấy "vẫn qua mastering", đó là tiếng đàn chứ không phải audio clips.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033900)
- **Ghi chú/Test (nếu có):** BUILD OK 934112 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Theo yêu cầu user: **nút không sáng (xám) = cho phép bypass đang bật; nút sáng = không bypass (xử lý bình thường)**. Đảo UI cả 2 strip (MixerStrip + TrackStripConsole): A bypass bật → xám, tắt → sáng xanh; ♪ bypass bật → xám, tắt → sáng tím. Củng cố DSP để bypass audio chắc chắn tác dụng:
1. `createMasteringRoute` **ưu tiên đọc `trackAudioBypassMap`** (sync từ state mỗi render) trước khi fallback track object — tránh stale object.
2. `__setTrackBypass('audio')` giờ **re-route LIVE mọi section sub-node** (`<trackId>_sub_<subId>`) cùng main node → audioclip item + section item bypass ngay khi bấm, kể cả đang play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033800)
- **Ghi chú/Test (nếu có):** BUILD OK 933696 bytes, node --check OK, `pytest` 86 passed. Tooltip nút ghi rõ: "XÁM = bypass đang bật, SÁNG = xử lý bình thường".
---
### [2026-08-03] Task: Fix màu nút bypass A/♪ + tách hoàn toàn đường SF (MIDI không còn lệ thuộc nút A)
- **Tóm tắt thay đổi:**
1. **Đường SF độc lập (fix lệ thuộc A)**: trước đây SF chảy qua chain DÙNG CHUNG (gainNode → fxEntry → ... → panner → routeGain) — khi bật nút A, routeGain=0 → MIDI mất FX dù ♪ chưa bật. Fix: track có midiItems giờ dựng **SF chain riêng**: `gainNode → sfEntry → [module instances RIÊNG (cùng loại/params)] → sfOut → sfPan → masterBus.input` — hoàn toàn tách khỏi chuỗi audio. `updateSfRouting`: ♪ off → SF → sfEntry (FX giữ nguyên dù A bật/tắt); ♪ on → SF → masterBus.dryInput. `rebuildTrackFxGraph` rebuild cả 2 chain + sync sfPan.pan. Scope rack: track MIDI hiển thị `sfAnalyser` (post-FX).
2. **Màu nút theo yêu cầu**: TrackStripConsole — A/♪ inactive = **xám** (`text-slate-500`), active = **sáng xanh** (A: `text-sky-300 bg-sky-500/30`) / **sáng tím** (♪: `text-fuchsia-300 bg-fuchsia-500/30`). MixerStrip bỏ fallback `?? masteringBypass` khỏi visual (state hoàn toàn độc lập: A chỉ đọc `audioBypass`, ♪ chỉ đọc `midiBypass`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033700)
- **Ghi chú/Test (nếu có):** BUILD OK 933049 bytes, node --check OK, `pytest` 86 passed.
---
### [2026-08-03] Task: Tách nút bypass track thành 2 nút (Audio vs MIDI) + fix loop 0→18 khi click tempo track
- **Tóm tắt thay đổi:**
1. **Tách bypass**: track giờ có `audioBypass` + `midiBypass` (migrate từ `masteringBypass` cũ — deserialize: `audio_bypass ?? mastering_bypass`, `midi_bypass ?? mastering_bypass`; serialize ghi cả 3). UI MixerStrip + TrackStripConsole: thay nút B đơn bằng **nút [A]** (bypass audio items = clips + sections; route kép dry/route như cũ — `trackAudioBypassMap` + `createMasteringRoute` đọc `audioBypass ?? masteringBypass`) và **nút [♪]** (bypass MIDI riêng — `trackMidiBypassMap` + `updateSfRouting`: khi bật, SF output → `masterBus.dryInput` trực tiếp, bỏ FX + mastering nhưng giữ CC7 mute/solo). `window.__setTrackBypass(trackId, 'audio'|'midi', on)`; giữ `__setTrackMasteringBypass` (set cả 2) cho tương thích.
2. **Fix loop**: click lên tempo track (hoặc click đơn bất kỳ) tạo selection RỖNG (start==end) → `hasSel` cũ FALSE → nút loop auto-derive 0→18 ghi đè. Fix: `hasSel` giờ nhận mọi selection state có tồn tại (`selectionStart !== null && selectionEnd !== null` — kể cả start==end) → không bao giờ tự derive khi user đã click lane.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033600)
- **Ghi chú/Test (nếu có):** BUILD OK 931110 bytes, node --check OK, `pytest` 86 passed.
---
### [2026-08-03] Task: Wave Observer + Module Vector Display Canvas trong FX Rack Panel (unified_fx_rack_panel_update.md) ### [2026-08-03] Task: Wave Observer + Module Vector Display Canvas trong FX Rack Panel (unified_fx_rack_panel_update.md)
- **Tóm tắt thay đổi:** Tích hợp theo spec cập nhật: - **Tóm tắt thay đổi:** Tích hợp theo spec cập nhật:
1. **Scope analysers per-track (post-FX)**: `getOrCreateTrackNode` thêm `scopeSplitter` + `scopeAnalyserL/R` (fftSize 2048) nối từ `pannerNode` (output context, SAU FX chain — đúng sơ đồ spec §III.3); `analyserNode` chính tăng 256→2048. Expose `window.__getTrackScopeAnalysers(trackId)` → {L, R, sr}. 1. **Scope analysers per-track (post-FX)**: `getOrCreateTrackNode` thêm `scopeSplitter` + `scopeAnalyserL/R` (fftSize 2048) nối từ `pannerNode` (output context, SAU FX chain — đúng sơ đồ spec §III.3); `analyserNode` chính tăng 256→2048. Expose `window.__getTrackScopeAnalysers(trackId)` → {L, R, sr}.