Gap 7: master volume automation lane — render_engine automation.volume_db (dB interp -30..+3 sau fader truoc normalize), schema master.automation, UI AUTOM panel MasterStripConsole + realtime scheduling tren masterBus.output.gain (play/seek/stop), serialize/parse, test_65 (177 passed)

This commit is contained in:
2026-08-23 09:05:39 +07:00
parent 2b5f396133
commit 94a0bc7401
6 changed files with 233 additions and 19 deletions
+118 -3
View File
@@ -1178,6 +1178,37 @@ function initMasterBus(ctx) {
function setMasterVolume(linear) {
if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime);
}
// Gap 7: master volume automation lane — schedule envelope tren master output
// gain (dB tuyen tinh, clamp -30..+3 nhu render_engine._apply_master_volume_automation).
// Trang thai tu React qua window.__masterAutoOn/__masterAutoVol (pattern
// window.__currentZoom). playheadSec = vi tri timeline giay luc bat dau nghe.
function applyMasterAutomationSchedule(playheadSec) {
const bus = masterBus;
if (!bus || !bus.output || !bus.output.gain) return;
const ctx = audioCtx || getAudioContext();
const now = ctx.currentTime;
const env = (window.__masterAutoOn && Array.isArray(window.__masterAutoVol))
? window.__masterAutoVol.filter(p => p && typeof p.time === 'number' && typeof p.db === 'number').slice().sort((a, b) => a.time - b.time)
: [];
bus.output.gain.cancelScheduledValues(now);
if (!env.length) { bus.output.gain.setValueAtTime(1, now); return; }
const ph = playheadSec || 0;
let idx = 0;
while (idx < env.length && env[idx].time < ph) idx++;
let initDb;
if (idx === 0) initDb = env[0].db;
else if (idx >= env.length) initDb = env[env.length - 1].db;
else {
const a = env[idx - 1], b = env[idx];
const span = b.time - a.time || 1;
initDb = a.db + (b.db - a.db) * Math.max(0, Math.min(1, (ph - a.time) / span));
}
bus.output.gain.setValueAtTime(Math.pow(10, Math.max(-30, Math.min(3, initDb)) / 20), now);
for (let k = idx; k < env.length; k++) {
const t = now + (env[k].time - ph);
if (t > now) bus.output.gain.linearRampToValueAtTime(Math.pow(10, Math.max(-30, Math.min(3, env[k].db)) / 20), t);
}
}
// Master L/R faders + LINK + MUTE - per-channel gain stage (output -> volSplit ->
// volLGain/volRGain -> volMerge -> muteGain -> analyser). time===undefined ->
@@ -2549,7 +2580,7 @@ const RefCompareModal = ({ open, onClose }) => {
};
// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ──
const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMasterVolL, masterVolR, setMasterVolR, masterLink, setMasterLink, masterMuted, setMasterMuted, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => {
const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMasterVolL, masterVolR, setMasterVolR, masterLink, setMasterLink, masterMuted, setMasterMuted, masterAutoVol, setMasterAutoVol, masterAutoOn, setMasterAutoOn, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => {
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
const [isMono, setIsMono] = React.useState(false);
const [pan, setPan] = React.useState(0.0);
@@ -2565,6 +2596,18 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
const panStartYRef = React.useRef(0);
const startPanValRef = React.useRef(0);
const [refOpen, setRefOpen] = React.useState(false);
const [autoOpen, setAutoOpen] = React.useState(false);
const addAutoPoint = () => {
const pts = (masterAutoVol || []).slice();
pts.push({ time: Math.round(pts.reduce((m, p) => Math.max(m, p.time || 0), 0) + 1), db: 0 });
setMasterAutoVol(pts);
};
const updAutoPoint = (i, key, val) => {
const pts = (masterAutoVol || []).slice();
pts[i] = Object.assign({}, pts[i], { [key]: val });
setMasterAutoVol(pts);
};
const delAutoPoint = (i) => setMasterAutoVol((masterAutoVol || []).filter((p, j) => j !== i));
const ensureAudio = () => {
getAudioContext();
@@ -2864,6 +2907,41 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
)
)
),
React.createElement("div", { className: "flex flex-col gap-0.5 my-1 shrink-0" },
React.createElement("div", { className: "flex items-center justify-between gap-1" },
React.createElement("button", {
id: "automBtn",
onClick: function() { setAutoOpen(function(p) { return !p; }); },
className: "btn-daw rounded px-1 py-0.5 text-[8px] font-bold" + (autoOpen ? " btn-teal-active" : ""),
title: "Master Volume Automation (Gap 7)"
}, "AUTOM"),
React.createElement("button", {
onClick: function() { setMasterAutoOn(function(p) { return !p; }); },
className: "text-[8px] px-1 py-0.5 rounded font-mono" + (masterAutoOn ? " text-emerald-400 bg-emerald-500/10" : " text-slate-500"),
title: "Bat/tat master volume automation"
}, masterAutoOn ? "ON" : "OFF")
),
autoOpen && React.createElement("div", { className: "flex flex-col gap-0.5 bg-slate-950 border border-slate-800 rounded p-1 max-h-32 overflow-y-auto" },
(masterAutoVol || []).map(function(pt, i) {
return React.createElement("div", { key: i, className: "flex items-center gap-1" },
React.createElement("input", {
type: "number", step: "0.1", min: "0", value: pt.time,
onChange: function(e) { updAutoPoint(i, 'time', parseFloat(e.target.value) || 0); },
className: "w-12 bg-slate-900 border border-slate-700 rounded px-0.5 text-[8px] font-mono text-slate-200"
}),
React.createElement("span", { className: "text-[8px] text-slate-500" }, "s"),
React.createElement("input", {
type: "number", step: "0.5", min: "-30", max: "3", value: pt.db,
onChange: function(e) { updAutoPoint(i, 'db', parseFloat(e.target.value) || 0); },
className: "w-12 bg-slate-900 border border-slate-700 rounded px-0.5 text-[8px] font-mono text-slate-200"
}),
React.createElement("span", { className: "text-[8px] text-slate-500" }, "dB"),
React.createElement("button", { onClick: function() { delAutoPoint(i); }, className: "text-[8px] text-red-400 hover:text-red-300 ml-auto" }, "x")
);
}),
React.createElement("button", { onClick: addAutoPoint, className: "text-[8px] text-cyan-400 hover:text-cyan-300 mt-0.5" }, "+ ADD POINT")
)
),
React.createElement("div", { className: "text-center text-[10px] font-bold font-mono text-slate-200 shrink-0" },
masterVolDisplay
),
@@ -11564,7 +11642,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
})(),
auto_compute_length: true,
tracks: mainTracks,
...((masteringSettings && Array.isArray(masteringSettings.vstFxChain) && masteringSettings.vstFxChain.length > 0) || (typeof masterVolume === 'number' && isFinite(masterVolume) && masterVolume !== 0) || (masterState && (masterState.volL !== 0 || masterState.volR !== 0 || masterState.muted || !masterState.link)) ? {
...((masteringSettings && Array.isArray(masteringSettings.vstFxChain) && masteringSettings.vstFxChain.length > 0) || (typeof masterVolume === 'number' && isFinite(masterVolume) && masterVolume !== 0) || (masterState && (masterState.volL !== 0 || masterState.volR !== 0 || masterState.muted || !masterState.link)) || (Array.isArray(window.__masterAutoVol) && window.__masterAutoVol.length > 0) ? {
master: {
fx_chain: masteringSettings.vstFxChain.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })),
volume_db: (typeof masterVolume === 'number' && isFinite(masterVolume)) ? masterVolume : 0,
@@ -11573,7 +11651,10 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
link_lr: masterState ? !!masterState.link : false,
muted: masterState ? !!masterState.muted : false,
mute: masterState ? !!masterState.muted : false,
bypass: !masteringSettings.masterConnected || !!masteringSettings.vstBypass
bypass: !masteringSettings.masterConnected || !!masteringSettings.vstBypass,
...((Array.isArray(window.__masterAutoVol) && window.__masterAutoVol.length > 0) ? {
automation: { volume_db: window.__masterAutoVol.map(p => ({ time: p.time, db: p.db })) }
} : {})
}
} : {})
},
@@ -11700,6 +11781,8 @@ const deserializeProjectFromSchema = (schemaObj) => {
masterVolR: (typeof _masterBus.volumeR_db === 'number' && isFinite(_masterBus.volumeR_db)) ? _masterBus.volumeR_db : ((typeof _masterBus.volumeL_db === 'number' && isFinite(_masterBus.volumeL_db)) ? _masterBus.volumeL_db : ((typeof _masterBus.volume_db === 'number' && isFinite(_masterBus.volume_db)) ? _masterBus.volume_db : 0)),
masterLink: !!_masterBus.link_lr,
masterMuted: !!(_masterBus.muted || _masterBus.mute),
masterAutoVol: (_masterBus.automation && Array.isArray(_masterBus.automation.volume_db)) ? _masterBus.automation.volume_db.filter(p => p && typeof p.time === 'number' && typeof p.db === 'number') : [],
masterAutoOn: !!(_masterBus.automation && Array.isArray(_masterBus.automation.volume_db) && _masterBus.automation.volume_db.length),
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0
};
};
@@ -17961,6 +18044,17 @@ const App = () => {
const [masterVolR, setMasterVolR] = useState(0); // dB - master R fader
const [masterLink, setMasterLink] = useState(true); // LINK L/R
const [masterMuted, setMasterMuted] = useState(false); // MUTE master
const [masterAutoVol, setMasterAutoVol] = useState([]); // Gap 7: master volume automation [{time s, db}]
const [masterAutoOn, setMasterAutoOn] = useState(false); // Gap 7: automation active
React.useEffect(() => {
window.__masterAutoVol = masterAutoVol;
window.__masterAutoOn = masterAutoOn;
}, [masterAutoVol, masterAutoOn]);
React.useEffect(() => {
if (isPlaying && typeof applyMasterAutomationSchedule === 'function') {
try { applyMasterAutomationSchedule(currentTimeRef.current || 0); } catch (e) { console.warn('[Auto] reschedule error:', e); }
}
}, [masterAutoVol, masterAutoOn, isPlaying]);
const [masterVU, setMasterVU] = useState(0); // 0-1
const [masterMeterPeak, setMasterMeterPeak] = useState(0);
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
@@ -19156,6 +19250,8 @@ const App = () => {
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
} else {
restoredTracks = (parsed.tracks || []).map(function(t) {
var rest = Object.assign({}, t); delete rest.height;
@@ -19229,6 +19325,8 @@ const App = () => {
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
@@ -19338,6 +19436,8 @@ const App = () => {
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
} else {
restoredTracks = (proj.tracks || []).map(t => {
const { height: _h, ...rest } = t;
@@ -19557,6 +19657,8 @@ const App = () => {
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
@@ -22932,6 +23034,7 @@ const App = () => {
} else {
startTrackPlayback(pt);
}
try { applyMasterAutomationSchedule(pt); } catch (e) {}
}
} else {
pendingRescheduleRef.current = null;
@@ -24082,6 +24185,8 @@ const App = () => {
// loop restart (rAF callback → React commit trễ hơn setTimeout 0) bị drop
// → loop pass 2+ câm. Set NGAY tại scheduler.
isPlayingRef.current = true;
// Gap 7: master volume automation — schedule envelope tu playhead.
try { applyMasterAutomationSchedule(offsetTime || 0); } catch (e) { console.warn('[Auto] schedule error:', e); }
// ⚠️ FIX: đồng bộ mastering NGAY khi play — MIDI item phải qua mastering
// FX (khi bật).
try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (e) {}
@@ -24684,6 +24789,8 @@ const App = () => {
const stopAllPlayback = (fade) => {
try {
isPlayingRef.current = false;
// Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng).
try { if (masterBus && masterBus.output && masterBus.output.gain) { const _actx = getAudioContext(); masterBus.output.gain.cancelScheduledValues(_actx.currentTime); masterBus.output.gain.setValueAtTime(1, _actx.currentTime); } } catch (e) { }
// Realtime VST FX: dừng session + khôi phục output→analyser.
try { fxRtStop(); } catch (e) { console.warn('[FX RT] stop error:', e); }
// Realtime VST FX per-track: dừng mọi session track + restore chain trực tiếp.
@@ -27917,6 +28024,8 @@ const App = () => {
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
} else {
restored = (proj.tracks || []).map(t => ({
...t,
@@ -30531,6 +30640,8 @@ STRICT CONSTRAINTS:
if (result.masterVolR !== undefined && result.masterVolR !== null) setMasterVolR(result.masterVolR);
if (result.masterLink !== undefined && result.masterLink !== null) setMasterLink(result.masterLink);
if (result.masterMuted !== undefined && result.masterMuted !== null) setMasterMuted(result.masterMuted);
if (result.masterAutoVol !== undefined) setMasterAutoVol(result.masterAutoVol);
if (result.masterAutoOn !== undefined) setMasterAutoOn(result.masterAutoOn);
} else {
restoredTracks = (parsed.tracks || []).map(function (t) {
const rest = Object.assign({}, t); delete rest.height;
@@ -33371,6 +33482,10 @@ STRICT CONSTRAINTS:
setMasterLink: setMasterLink,
masterMuted: masterMuted,
setMasterMuted: setMasterMuted,
masterAutoVol: masterAutoVol,
setMasterAutoVol: setMasterAutoVol,
masterAutoOn: masterAutoOn,
setMasterAutoOn: setMasterAutoOn,
showMasteringModal: showMasteringModal,
setShowMasteringModal: setShowMasteringModal,
masteringSettings: masteringSettings,
File diff suppressed because one or more lines are too long