FEAT: thêm tính năng hiển thị ở master track

This commit is contained in:
2026-07-29 22:13:54 +07:00
parent 7b5d4bf27a
commit 4cead1010e
4 changed files with 178 additions and 18 deletions
+155 -8
View File
@@ -31,6 +31,81 @@ const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// Storage for server-side file IDs mapped to track IDs // Storage for server-side file IDs mapped to track IDs
let serverFileIdMap = {}; let serverFileIdMap = {};
let audioCtx; let audioCtx;
let masterBus = null; // { input, compressor, analyser, output, masteringActive }
function initMasterBus(ctx) {
if (masterBus) return masterBus;
masterBus = {
input: ctx.createGain(),
compressor: ctx.createDynamicsCompressor(),
analyser: ctx.createAnalyser(),
output: ctx.createGain(),
masteringActive: false,
// Mastering processing nodes (created on demand)
eqNodes: null,
masteringInput: null,
masteringOutput: null
};
masterBus.analyser.fftSize = 256;
masterBus.compressor.threshold.value = -6;
masterBus.compressor.knee.value = 6;
masterBus.compressor.ratio.value = 4;
masterBus.compressor.attack.value = 0.003;
masterBus.compressor.release.value = 0.25;
masterBus.output.gain.value = 1;
// Default routing: input compressor analyser output destination
masterBus.input.connect(masterBus.compressor);
masterBus.compressor.connect(masterBus.analyser);
masterBus.analyser.connect(masterBus.output);
masterBus.output.connect(ctx.destination);
return masterBus;
}
function setMasterVolume(linear) {
if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime);
}
function toggleMasteringOnMaster(activate, nodes) {
if (!masterBus) return;
const ctx = audioCtx;
const now = ctx.currentTime;
if (activate && nodes && !masterBus.masteringActive) {
// Disconnect default routing
masterBus.input.disconnect();
masterBus.compressor.disconnect();
// Insert mastering chain: input compressor eqLow eqMid1 eqMid2 eqHigh boostGain compressor2 analyser output
masterBus.input.connect(masterBus.compressor);
masterBus.compressor.connect(nodes.eqLowFilter);
nodes.eqLowFilter.connect(nodes.eqMid1Filter);
nodes.eqMid1Filter.connect(nodes.eqMid2Filter);
nodes.eqMid2Filter.connect(nodes.eqHighFilter);
nodes.eqHighFilter.connect(nodes.maximizerBoostGain);
nodes.maximizerBoostGain.connect(nodes.maximizerCompressor);
nodes.maximizerCompressor.connect(masterBus.analyser);
masterBus.analyser.connect(masterBus.output);
masterBus.masteringActive = true;
masterBus.eqNodes = nodes;
} else if (!activate && masterBus.masteringActive) {
// Disconnect mastering chain
masterBus.input.disconnect();
masterBus.compressor.disconnect();
if (masterBus.eqNodes) {
try { masterBus.eqNodes.eqLowFilter.disconnect(); } catch(e) {}
try { masterBus.eqNodes.eqMid1Filter.disconnect(); } catch(e) {}
try { masterBus.eqNodes.eqMid2Filter.disconnect(); } catch(e) {}
try { masterBus.eqNodes.eqHighFilter.disconnect(); } catch(e) {}
try { masterBus.eqNodes.maximizerBoostGain.disconnect(); } catch(e) {}
try { masterBus.eqNodes.maximizerCompressor.disconnect(); } catch(e) {}
}
// Restore default routing
masterBus.input.connect(masterBus.compressor);
masterBus.compressor.connect(masterBus.analyser);
masterBus.analyser.connect(masterBus.output);
masterBus.masteringActive = false;
masterBus.eqNodes = null;
}
}
function getAudioContext() { function getAudioContext() {
if (!audioCtx) { if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)(); audioCtx = new (window.AudioContext || window.webkitAudioContext)();
@@ -6704,6 +6779,7 @@ const MasteringModal = ({ isOpen, onClose }) => {
}); });
const [isPlaying, setIsPlaying] = React.useState(false); const [isPlaying, setIsPlaying] = React.useState(false);
const [peakLevels, setPeakLevels] = React.useState({ inPeak: 0.05, outPeak: 0.05 }); const [peakLevels, setPeakLevels] = React.useState({ inPeak: 0.05, outPeak: 0.05 });
const [masterConnected, setMasterConnected] = React.useState(false);
const audioRef = React.useRef({ ctx: null, nodes: {}, source: null }); const audioRef = React.useRef({ ctx: null, nodes: {}, source: null });
const eqCanvasRef = React.useRef(null); const eqCanvasRef = React.useRef(null);
@@ -7022,9 +7098,30 @@ const MasteringModal = ({ isOpen, onClose }) => {
if (!isOpen) { if (!isOpen) {
stopAudioDemo(); stopAudioDemo();
knobsInitializedRef.current = false; knobsInitializedRef.current = false;
if (masterConnected) {
toggleMasteringOnMaster(false, null);
setMasterConnected(false);
}
} }
}, [isOpen]); }, [isOpen]);
// Sync mastering chain with master bus
React.useEffect(() => {
const nodes = audioRef.current.nodes;
const hasNodes = nodes.eqLowFilter && nodes.maximizerCompressor;
if (masterConnected && hasNodes) {
// Ensure audio engine is initialized with proper values
initOzoneAudioEngine();
toggleMasteringOnMaster(true, nodes);
updateAudioGraphValues();
} else if (!masterConnected) {
toggleMasteringOnMaster(false, null);
}
return () => {
if (masterConnected) toggleMasteringOnMaster(false, null);
};
}, [masterConnected]);
if (!isOpen) return null; if (!isOpen) return null;
const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name })); const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name }));
@@ -7086,6 +7183,10 @@ const MasteringModal = ({ isOpen, onClose }) => {
<div className="flex items-center gap-3 text-xs oz-font-mono"> <div className="flex items-center gap-3 text-xs oz-font-mono">
<span className="text-slate-400 text-[11px]">Target LUFS:</span> <span className="text-slate-400 text-[11px]">Target LUFS:</span>
<span className="text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded">-11.0 LUFS</span> <span className="text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded">-11.0 LUFS</span>
<button onClick={() => { initOzoneAudioEngine(); setMasterConnected(prev => !prev); }}
className={`px-2 py-0.5 rounded text-[10px] font-bold border transition-colors ${masterConnected ? 'bg-emerald-700 border-emerald-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`}>
{masterConnected ? 'Master ON' : 'Master OFF'}
</button>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 ml-2"> <button onClick={onClose} className="text-slate-500 hover:text-slate-300 ml-2">
<i data-lucide="x" className="w-4 h-4"></i> <i data-lucide="x" className="w-4 h-4"></i>
</button> </button>
@@ -7851,6 +7952,9 @@ const App = () => {
var saved = localStorage.getItem('studio_mixer_height'); var saved = localStorage.getItem('studio_mixer_height');
return saved ? parseInt(saved) : 200; return saved ? parseInt(saved) : 200;
}()); }());
const [masterVolume, setMasterVolume] = useState(0); // dB
const [masterVU, setMasterVU] = useState(0); // 0-1
const [masterMeterPeak, setMasterMeterPeak] = useState(0);
const [rightSidebarWidth, setRightSidebarWidth] = useState(320); const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
const [tcpWidth, setTcpWidth] = useState(320); const [tcpWidth, setTcpWidth] = useState(320);
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50); const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
@@ -11000,7 +11104,7 @@ const App = () => {
source.connect(volumeGainNode); source.connect(volumeGainNode);
volumeGainNode.connect(pannerNode); volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode); pannerNode.connect(fadeGainNode);
fadeGainNode.connect(context.destination); fadeGainNode.connect(masterBus ? masterBus.input : context.destination);
source.start(context.currentTime, offsetBuffer); source.start(context.currentTime, offsetBuffer);
activeSourcesRef.current = [source]; activeSourcesRef.current = [source];
activeTrackNodesRef.current[st.trackId] = { activeTrackNodesRef.current[st.trackId] = {
@@ -11018,7 +11122,7 @@ const App = () => {
const osc = ctx.createOscillator(); const osc = ctx.createOscillator();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
osc.connect(gainNode); osc.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(masterBus ? masterBus.input : ctx.destination);
osc.frequency.setValueAtTime(isDownbeat ? 1000 : 800, time); osc.frequency.setValueAtTime(isDownbeat ? 1000 : 800, time);
gainNode.gain.setValueAtTime(0.08, time); gainNode.gain.setValueAtTime(0.08, time);
@@ -11272,7 +11376,9 @@ const App = () => {
gainNode.gain.setValueAtTime(volLinear, context.currentTime); gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner(); const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime); pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
pannerNode.connect(context.destination); // Route through master bus if available, else direct to destination
const dest = masterBus ? masterBus.input : context.destination;
pannerNode.connect(dest);
let fxStopFn; let fxStopFn;
if (track.fxType === 'chorus') { if (track.fxType === 'chorus') {
const fxInput = context.createGain(); const fxInput = context.createGain();
@@ -12066,7 +12172,7 @@ const App = () => {
} }
}; };
const monitorGain = track.monitoringEnabled ? context.destination : null; const monitorGain = track.monitoringEnabled ? (masterBus ? masterBus.input : context.destination) : null;
await audioRec.start(monitorGain, track.monitoringEnabled); await audioRec.start(monitorGain, track.monitoringEnabled);
activeAudioRecordersRef.current[track.id] = audioRec; activeAudioRecordersRef.current[track.id] = audioRec;
} catch (err) { } catch (err) {
@@ -16106,6 +16212,28 @@ const App = () => {
}, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel,
showMediaExplorer, showFxRack, showMidiEvents, panelPositions, showMediaExplorer, showFxRack, showMidiEvents, panelPositions,
rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]); rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]);
// Master VU meter animation loop
const masterVUAnimRef = useRef(null);
useEffect(() => {
function tick() {
if (masterBus && masterBus.analyser) {
const data = new Uint8Array(128);
masterBus.analyser.getByteTimeDomainData(data);
let peak = 0;
for (let i = 0; i < data.length; i++) {
const v = Math.abs(data[i] - 128) / 128;
if (v > peak) peak = v;
}
setMasterVU(peak);
setMasterMeterPeak(prev => Math.max(prev * 0.97, peak));
}
masterVUAnimRef.current = requestAnimationFrame(tick);
}
masterVUAnimRef.current = requestAnimationFrame(tick);
return () => { if (masterVUAnimRef.current) cancelAnimationFrame(masterVUAnimRef.current); };
}, []);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
className: "h-full w-full flex flex-col bg-[#1e1e1e]" className: "h-full w-full flex flex-col bg-[#1e1e1e]"
}, /*#__PURE__*/React.createElement("header", { }, /*#__PURE__*/React.createElement("header", {
@@ -18804,19 +18932,38 @@ const App = () => {
})))), /*#__PURE__*/React.createElement("div", { })))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-x-auto p-1.5 gap-1.5 items-stretch" className: "flex-1 flex overflow-x-auto p-1.5 gap-1.5 items-stretch"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "flex flex-col items-stretch w-14 shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm" className: "flex flex-col items-stretch w-16 shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "text-[9px] font-bold text-zinc-300 uppercase tracking-wider w-full text-center py-0.5 bg-[#222] border-b border-black/60 shrink-0" className: "text-[9px] font-bold text-zinc-300 uppercase tracking-wider w-full text-center py-0.5 bg-[#222] border-b border-black/60 shrink-0"
}, "MASTER"), /*#__PURE__*/React.createElement("div", { }, "MASTER"), /*#__PURE__*/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"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "w-3 bg-[#161616] rounded border border-black/60" className: "w-5 rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60 flex flex-col items-center"
}, /*#__PURE__*/React.createElement("input", {
type: "range",
min: "-60", max: "12", step: "0.5",
value: masterVolume,
onChange: e => { const v = parseFloat(e.target.value); setMasterVolume(v); if (masterBus) setMasterVolume(v); const linear = v <= -50 ? 0 : Math.pow(10, v / 20); if (masterBus) masterBus.output.gain.setValueAtTime(linear, getAudioContext().currentTime); },
className: "absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10",
style: { writingMode: 'vertical-lr', direction: 'rtl' }
}), /*#__PURE__*/React.createElement("div", { }), /*#__PURE__*/React.createElement("div", {
className: "flex-1 w-full flex flex-col-reverse items-center pb-0.5",
style: { background: 'linear-gradient(to top, #38bdf8, #f59e0b, #ef4444)' }
}, /*#__PURE__*/React.createElement("div", {
className: "w-full bg-[#0d0d0d] transition-all duration-75",
style: { height: `${(1 - Math.min(1, Math.max(0, (masterVolume + 60) / 72))) * 100}%` }
}))), /*#__PURE__*/React.createElement("div", {
className: "w-2.5 rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60" className: "w-2.5 rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
className: "absolute bottom-0 w-full bg-white/40 transition-all duration-75", className: "absolute bottom-0 w-full transition-all duration-75",
style: { height: '50%' } style: { height: `${Math.min(100, masterVU * 100)}%`, background: masterVU > 0.85 ? '#ef4444' : masterVU > 0.7 ? '#f59e0b' : '#38bdf8' }
}))), /*#__PURE__*/React.createElement("div", { }))), /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-1 justify-center py-0.5 bg-[#222] border-t border-black/60 shrink-0"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setShowMasteringModal(true),
className: "px-1 py-0.5 text-[7px] font-bold uppercase bg-cyan-900 hover:bg-cyan-800 text-cyan-300 rounded transition-colors",
title: "Open Mastering Suite"
}, "Mastering")), /*#__PURE__*/React.createElement("div", {
className: "text-[8px] font-mono font-bold text-zinc-500 w-full text-center py-0.5 bg-[#222] border-t border-black/60 shrink-0" className: "text-[8px] font-mono font-bold text-zinc-500 w-full text-center py-0.5 bg-[#222] border-t border-black/60 shrink-0"
}, "MAIN OUT")), activeTracks.length > 0 && /*#__PURE__*/React.createElement("div", { }, "MAIN OUT")), activeTracks.length > 0 && /*#__PURE__*/React.createElement("div", {
className: "w-px bg-zinc-700 shrink-0 self-stretch mx-0.5" className: "w-px bg-zinc-700 shrink-0 self-stretch mx-0.5"
File diff suppressed because one or more lines are too long
Binary file not shown.
+1 -1
View File
@@ -23,7 +23,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=202607290941" defer></script> <script src="/static/js/app.precompiled.js?v=202607292209" 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 {