FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel

This commit is contained in:
2026-08-03 15:47:10 +07:00
parent 6f55d36085
commit a9da813cb1
28 changed files with 2076 additions and 233 deletions
+87 -8
View File
@@ -62,7 +62,37 @@ const assignTrackMidiChannel = (track, tracks) => {
// Storage for server-side file IDs mapped to track IDs
let serverFileIdMap = {};
let audioCtx;
let masterBus = null; // { input, compressor, analyser, output, masteringActive }
let masterBus = null; // { input, compressor, analyser, output, masteringActive, dryInput, dryOutput }
// 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 = {};
// 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);
const routeGain = ctx.createGain();
const dryGain = ctx.createGain();
const masterDest = bus ? bus.input : ctx.destination;
const dryDest = (bus && bus.dryInput) ? bus.dryInput : ctx.destination;
routeGain.gain.value = bypass ? 0 : 1;
dryGain.gain.value = bypass ? 1 : 0;
routeGain.connect(masterDest);
dryGain.connect(dryDest);
return { routeGain, dryGain };
}
// Live-toggle a route with a short crossfade (click-free).
function setMasteringRoute(route, bypass) {
if (!route || !audioCtx) return;
const t = audioCtx.currentTime;
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);
}
function makeDistortionCurve(k) {
const n_samples = 44100;
@@ -288,6 +318,15 @@ function initMasterBus(ctx) {
const output = ctx.createGain();
output.gain.value = 1.0;
// Per-track mastering-bypass dry bus: tracks with bypass ON feed into
// dryInput -> dryOutput -> output, skipping the mastering modules
// (EQ / Imager / Maximizer) while still passing the master volume fader
// and the master output metering.
const dryInput = ctx.createGain();
const dryOutput = ctx.createGain();
dryInput.connect(dryOutput);
dryOutput.connect(output);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
@@ -307,6 +346,8 @@ function initMasterBus(ctx) {
analyser,
output,
masteringActive: false,
dryInput,
dryOutput,
// Analysers for metering
inputAnalyser,
@@ -754,6 +795,7 @@ 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 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';
@@ -776,7 +818,17 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
title: "Solo",
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
}, "S")),
}, "S"), React.createElement("button", {
onClick: e => {
e.stopPropagation();
const next = !track.masteringBypass;
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
// Live audio re-route (applies immediately to playing tracks).
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")),
React.createElement("div", {
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
}, React.createElement("div", {
@@ -7986,6 +8038,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
pan: t.pan || 0.0,
mute: t.muted || false,
solo: t.solo || false,
mastering_bypass: t.masteringBypass || false,
instrument_id: t.instrumentId || null,
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
instrument_name: t.instrumentName || null,
@@ -8061,6 +8114,7 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
pan: t.pan || 0.0,
muted: t.mute || false,
solo: t.solo || false,
masteringBypass: t.mastering_bypass || false,
color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'),
startTime: t.start_time || 0,
height: t.height || 140,
@@ -11720,6 +11774,20 @@ const App = () => {
return next;
});
};
// Live per-track mastering bypass: updates the routing map + re-routes any
// active track node immediately (called from MixerStrip's B button).
window.__setTrackMasteringBypass = function(trackId, bypass) {
trackMasteringBypassMap[trackId] = !!bypass;
const node = activeTrackNodesRef.current[trackId];
if (node && node.route) setMasteringRoute(node.route, !!bypass);
};
// Keep the routing map in sync with the tracks state (loads, undo, AI ops).
useEffect(() => {
(tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
(sessionTabs || []).forEach(st => (st.tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }));
}, [tracks, sessionTabs]);
window.__toggleMediaExplorerRef = function() {
setShowMediaExplorer(function(p) {
const next = !p;
@@ -12413,6 +12481,10 @@ const App = () => {
showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info');
} catch(e) {
console.warn('restoreLastSessionProject failed:', e);
// Stale session id (project deleted / DB reset): clear it so the error
// does not repeat on every page load.
localStorage.removeItem('sonic_project_id');
localStorage.removeItem('sonic_project_name');
}
};
@@ -15270,7 +15342,10 @@ const App = () => {
source.connect(volumeGainNode);
volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode);
fadeGainNode.connect(masterBus ? masterBus.input : context.destination);
// Route through mastering chain unless this track has mastering bypass ON.
const route = createMasteringRoute(context, { masteringBypass: !!trackMasteringBypassMap[st.trackId] }, masterBus);
fadeGainNode.connect(route.routeGain);
fadeGainNode.connect(route.dryGain);
source.start(context.currentTime, offsetBuffer);
activeSourcesRef.current = [source];
activeTrackNodesRef.current[st.trackId] = {
@@ -15548,13 +15623,15 @@ const App = () => {
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
// Ensure master bus is initialized for MAIN OUT routing
if (!masterBus) initMasterBus(context);
// Route through master bus if available, else direct to destination
const dest = masterBus ? masterBus.input : context.destination;
const analyserNode = context.createAnalyser();
analyserNode.fftSize = 256;
pannerNode.connect(analyserNode);
analyserNode.connect(dest);
// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
// dry bus (bypass). Live-toggled via setMasteringRoute(node.route, ...).
const route = createMasteringRoute(context, track, masterBus);
analyserNode.connect(route.routeGain);
analyserNode.connect(route.dryGain);
let fxStopFn;
if (track.fxType === 'chorus') {
@@ -15570,7 +15647,7 @@ const App = () => {
} else {
gainNode.connect(pannerNode);
}
node = { gainNode, pannerNode, fxStopFn, analyserNode };
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
activeTrackNodesRef.current[track.id] = node;
}
return node.gainNode;
@@ -18137,7 +18214,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, color: colors[idx % colors.length],
muted: false, solo: false, masteringBypass: false, color: colors[idx % colors.length],
markers: [], serverFileId: null, clips: [], sections: [],
midiItems: [midiItem],
isArmed: false, monitoringEnabled: true,
@@ -18199,6 +18276,7 @@ const App = () => {
pan: 0,
muted: false,
solo: false,
masteringBypass: false,
color: selectColor,
markers: [],
serverFileId: null,
@@ -18386,6 +18464,7 @@ const App = () => {
pan: 0,
muted: false,
solo: false,
masteringBypass: false,
color: selectColor,
markers: [],
serverFileId: null,
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -275,7 +275,10 @@ ${rules.join('\n')}` },
} else {
response = await fetch(`${origin}/api/v1/ai/proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
},
body: JSON.stringify({ url, headers, body })
});
}