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
// tracks state so ANY audio path can route without holding the track object.
const trackMasteringBypassMap = {};
const trackAudioBypassMap = {};
const trackMidiBypassMap = {};
// Build the dual routing for one track: routeGain -> mastering chain (normal),
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
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 dryGain = ctx.createGain();
const masterDest = bus ? bus.input : ctx.destination;
@@ -80,18 +90,30 @@ function createMasteringRoute(ctx, track, bus) {
dryGain.gain.value = bypass ? 1 : 0;
routeGain.connect(masterDest);
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) {
if (!route || !audioCtx) return;
const t = audioCtx.currentTime;
if (!route) return;
const on = !!bypass;
route.routeGain.gain.cancelScheduledValues(t);
route.dryGain.gain.cancelScheduledValues(t);
route.routeGain.gain.setTargetAtTime(on ? 0 : 1, t, 0.02);
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02);
try {
const ctx = (typeof getAudioContext === 'function') ? getAudioContext() : null;
if (!ctx) return;
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
@@ -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 isMuted = track.muted;
const isSoloed = track.solo;
const isBypassed = track.masteringBypass;
const isAudioBypassed = !!track.audioBypass;
const isMidiBypassed = !!track.midiBypass;
const vol = track.volumeDb != null ? track.volumeDb : 0;
var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100));
var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e';
@@ -1114,14 +1137,25 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
}, "S"), React.createElement("button", {
onClick: e => {
e.stopPropagation();
const next = !track.masteringBypass;
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
const next = !(track.audioBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { audioBypass: next });
// 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",
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')
}, "B")),
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 " + (isAudioBypassed ? 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100' : 'bg-sky-400 text-black border-sky-300')
}, "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", {
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
}, React.createElement("div", {
@@ -1472,7 +1506,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
var trackColor = track.color || '#06b6d4';
var isMuted = track.muted;
var isSoloed = track.solo;
var isBypassed = track.masteringBypass;
var isBypassed = !!(track.audioBypass ?? track.masteringBypass);
var isArmed = track.isArmed;
var trackName = track.name || 'Track ' + (index + 1);
var isMicActive = track.inputSource?.deviceType === 'MICROPHONE';
@@ -1618,14 +1652,26 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
React.createElement("button", {
onClick: function(e) {
e.stopPropagation();
var next = !track.masteringBypass;
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
var next = !(track.audioBypass ?? track.masteringBypass);
if (onUpdateTrack) onUpdateTrack(track.id, { audioBypass: next });
// 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" : ""),
title: "Bypass: track KHÔNG qua FX + mastering ở Main out"
}, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })),
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 Audio (clips + sections): XÁM = bypass đang bật (bỏ FX + mastering), SÁNG XANH = xử lý bình thường"
}, "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", {
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" : ""),
@@ -8388,7 +8434,9 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
pan: t.pan || 0.0,
mute: t.muted || 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_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
instrument_name: t.instrumentName || null,
@@ -8465,7 +8513,9 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
pan: t.pan || 0.0,
muted: t.mute || 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'),
startTime: t.start_time || 0,
height: t.height || 140,
@@ -12893,12 +12943,34 @@ const App = () => {
});
};
// Live per-track mastering bypass: updates the routing map + re-routes any
// active track node immediately (called from MixerStrip's B button).
// Live per-track bypass (split into two buttons per user request):
// - '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) {
trackAudioBypassMap[trackId] = !!bypass;
trackMasteringBypassMap[trackId] = !!bypass;
trackMidiBypassMap[trackId] = !!bypass;
const node = activeTrackNodesRef.current[trackId];
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
@@ -12913,7 +12985,11 @@ const App = () => {
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));
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
// the per-track gain cannot silence them. Every MIDI track owns a
// dedicated channel (ensureTrackMidiChannel) mute/solo via CC7 (channel
@@ -13450,7 +13526,11 @@ const App = () => {
// sessionTabs declarations (TDZ-safe).
useEffect(() => {
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;
list.forEach(t => {
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
@@ -13458,7 +13538,11 @@ const App = () => {
trackMuteSoloSigRef.current[t.id] = sig;
const audible = computeTrackAudibleGain(list, t) > 0;
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
// FluidSynth-rendered notes respect mute/solo too.
if ((t.midiItems && t.midiItems.length > 0) || t.type === 'MIDI') {
@@ -16558,7 +16642,7 @@ const App = () => {
volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode);
// 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.dryGain);
source.start(context.currentTime, offsetBuffer);
@@ -16893,12 +16977,50 @@ const App = () => {
} else {
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
// 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));
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;
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();
}
return node.gainNode;
@@ -16924,6 +17046,22 @@ const App = () => {
tail = mod.output;
});
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) {
console.warn('rebuildTrackFxGraph error:', e);
}
@@ -16942,7 +17080,20 @@ const App = () => {
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);
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) {
window.SonicSF.setOutputDestination(node.gainNode);
return;
@@ -16960,7 +17111,10 @@ const App = () => {
window.__updateSfRouting = updateSfRouting;
window.__getTrackScopeAnalysers = (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 };
};
const getOrCreateSubTrackNode = (track, subTrack, context) => {
@@ -19544,7 +19698,7 @@ const App = () => {
id: 'midi_track_' + now + '_' + idx,
name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')),
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: [],
midiItems: [midiItem],
isArmed: false, monitoringEnabled: true,
@@ -19607,6 +19761,8 @@ const App = () => {
muted: false,
solo: false,
masteringBypass: false,
audioBypass: false,
midiBypass: false,
color: selectColor,
markers: [],
serverFileId: null,
@@ -19795,6 +19951,8 @@ const App = () => {
muted: false,
solo: false,
masteringBypass: false,
audioBypass: false,
midiBypass: false,
color: selectColor,
markers: [],
serverFileId: null,
@@ -23026,7 +23184,7 @@ const App = () => {
// selection exists yet. Previously this always overwrote the
// selection with 0 (contentEnd + 2 bars).
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|| (selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart);
|| (selectionStart !== null && selectionEnd !== null);
if (!hasSel) {
const bpmVal = parseInt(bpm) || 120;
const secPerBar = (60.0 / bpmVal) * 4;
File diff suppressed because one or more lines are too long