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:
@@ -63,6 +63,22 @@ def _find_default_sf2() -> str:
|
||||
return p
|
||||
return ""
|
||||
|
||||
def _apply_master_volume_automation(buf, sr, nodes):
|
||||
"""Gap 7: master volume automation lane (master.automation.volume_db).
|
||||
|
||||
nodes: [{time: giây, db}] — envelope tuyến tính dB, clamp -30..+3 dB như
|
||||
track automation (sub_tab_dsp). Áp TRÊN master_buffer (2, N) — trả copy."""
|
||||
nodes = [n for n in (nodes or []) if n.get("time") is not None and n.get("db") is not None]
|
||||
if not nodes:
|
||||
return buf
|
||||
nodes = sorted(nodes, key=lambda x: float(x["time"]))
|
||||
n_samples = buf.shape[1]
|
||||
times = np.array([float(n["time"]) for n in nodes])
|
||||
dbs = np.clip(np.array([float(n["db"]) for n in nodes]), -30.0, 3.0)
|
||||
sample_times = np.arange(n_samples) / sr
|
||||
interp = np.interp(sample_times, times, dbs, left=dbs[0], right=dbs[-1])
|
||||
return buf * (10.0 ** (interp / 20.0))
|
||||
|
||||
# ── Builtin custom FX (spec fx_chain_architecture §2 Step 2: Custom FX) ─────
|
||||
# Track FX chain slots: eq / eqpro / compressor / limiter / exciter / rebalance
|
||||
# (khớp tham số WebAudio track FX modules + EQ Pro bands) + legacy chorus/reverb
|
||||
@@ -907,6 +923,14 @@ class PythonRenderEngine:
|
||||
if vol_l:
|
||||
master_buffer *= 10 ** (vol_l / 20.0)
|
||||
|
||||
# Gap 7: master volume automation — envelope áp SAU fader, TRƯỚC
|
||||
# normalize (fader cuối; automation = lớp âm lượng trên fader).
|
||||
_auto = master.get("automation") or {}
|
||||
_auto_vol = _auto.get("volume_db") or []
|
||||
if _auto_vol:
|
||||
master_buffer = _apply_master_volume_automation(
|
||||
master_buffer, self.sample_rate, _auto_vol)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
max_peak = np.max(np.abs(master_buffer))
|
||||
if max_peak > 1.0:
|
||||
|
||||
@@ -50,6 +50,22 @@
|
||||
"fx_chain": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/FXPlugin" }
|
||||
},
|
||||
"automation": {
|
||||
"type": "object",
|
||||
"description": "Gap 7: master automation lanes (volume_db)",
|
||||
"properties": {
|
||||
"volume_db": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"time": { "type": "number", "description": "giây" },
|
||||
"db": { "type": "number", "minimum": -30.0, "maximum": 3.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+118
-3
@@ -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
@@ -51,7 +51,7 @@
|
||||
<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/chordTheory.js?v=202608211300"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212310" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212325" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -806,3 +806,54 @@ def test_64_soundfont_render_via_bridge(monkeypatch, tmp_path):
|
||||
engine.render_project(project, out)
|
||||
data, _ = _read_wav(out)
|
||||
assert float(np.max(np.abs(data))) > 0.01, "engine render soundfont im lặng — fallback oscillator?"
|
||||
|
||||
# ── 5.11 Gap 7: master volume automation lane ─────────────────────────────
|
||||
def test_65_master_volume_automation(monkeypatch, tmp_path, fx_bridge):
|
||||
"""Gap 7: master.automation.volume_db [{time,db}] — envelope áp sau fader,
|
||||
trước normalize: trước node 1s giữ nguyên, sau node 1s gain ~ -12dB (0.25)."""
|
||||
monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge)
|
||||
if not os.path.isfile(DAW_BRIDGE):
|
||||
pytest.skip("daw_vst_bridge.exe chưa build")
|
||||
monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE)
|
||||
from app.api.v1 import plugins as pl
|
||||
monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge")
|
||||
from app.core.render_engine import PythonRenderEngine
|
||||
engine = PythonRenderEngine()
|
||||
|
||||
def project(auto_nodes):
|
||||
track = _midi_track()
|
||||
track["items"][0]["source_data"]["notes"][0]["duration_beats"] = 4.0 # phủ cả 2s
|
||||
track["volume_db"] = -12.0 # tránh normalize (>1.0)
|
||||
p = {
|
||||
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
||||
"main_session": {
|
||||
"length_bars": 1.0,
|
||||
"tracks": [track],
|
||||
"master": {"fx_chain": [], "bypass": False, "volume_db": 0.0},
|
||||
},
|
||||
"section_store": {},
|
||||
}
|
||||
if auto_nodes is not None:
|
||||
p["main_session"]["master"]["automation"] = {"volume_db": auto_nodes}
|
||||
return p
|
||||
|
||||
def rms(path, start_s, dur_s):
|
||||
data, sr = _read_wav(path)
|
||||
i0, i1 = int(start_s * sr), int((start_s + dur_s) * sr)
|
||||
return float(np.sqrt(np.mean(data[:, i0:i1] ** 2)))
|
||||
|
||||
out_base = str(tmp_path / "auto_base.wav")
|
||||
out_env = str(tmp_path / "auto_env.wav")
|
||||
engine.render_project(project(None), out_base)
|
||||
# Hai đoạn phẳng + chuyển nhanh 0dB→-12dB quanh t=1s (interp dB tuyến tính
|
||||
# giữa node liền kề; đoạn phẳng không bị ảnh hưởng bởi ramp lân cận).
|
||||
engine.render_project(project([
|
||||
{"time": 0.0, "db": 0.0}, {"time": 0.9, "db": 0.0},
|
||||
{"time": 1.0, "db": -12.0}, {"time": 2.0, "db": -12.0},
|
||||
]), out_env)
|
||||
base0 = rms(out_base, 0.1, 0.6)
|
||||
env0 = rms(out_env, 0.1, 0.6)
|
||||
env1 = rms(out_env, 1.15, 0.6)
|
||||
assert env0 == pytest.approx(base0, rel=0.05), f"đoạn 0dB phải giữ nguyên: {env0} vs {base0}"
|
||||
ratio = env1 / max(env0, 1e-9)
|
||||
assert 0.20 < ratio < 0.30, f"envelope -12dB → gain ~0.25: {ratio}"
|
||||
|
||||
Reference in New Issue
Block a user