feat(mixer): Master + Track Strip Console per specs 47/48
This commit is contained in:
+328
-45
@@ -738,7 +738,11 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
|||||||
const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings }) => {
|
const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings }) => {
|
||||||
const [isFxActive, setIsFxActive] = React.useState(true);
|
const [isFxActive, setIsFxActive] = React.useState(true);
|
||||||
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
|
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
|
||||||
|
const [isMuted, setIsMuted] = React.useState(false);
|
||||||
|
const [isMono, setIsMono] = React.useState(false);
|
||||||
|
const [pan, setPan] = React.useState(0.0);
|
||||||
|
const [panText, setPanText] = React.useState('center');
|
||||||
|
|
||||||
const vuCanvasRef = React.useRef(null);
|
const vuCanvasRef = React.useRef(null);
|
||||||
const testSourceRef = React.useRef(null);
|
const testSourceRef = React.useRef(null);
|
||||||
const animFrameRef = React.useRef(null);
|
const animFrameRef = React.useRef(null);
|
||||||
@@ -746,6 +750,10 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
const peakRRef = React.useRef(null);
|
const peakRRef = React.useRef(null);
|
||||||
const rmsValRef = React.useRef(null);
|
const rmsValRef = React.useRef(null);
|
||||||
const dbReadoutRef = React.useRef(null);
|
const dbReadoutRef = React.useRef(null);
|
||||||
|
const panPointerRef = React.useRef(null);
|
||||||
|
const isPanDraggingRef = React.useRef(false);
|
||||||
|
const panStartYRef = React.useRef(0);
|
||||||
|
const startPanValRef = React.useRef(0);
|
||||||
|
|
||||||
const ensureAudio = () => {
|
const ensureAudio = () => {
|
||||||
getAudioContext();
|
getAudioContext();
|
||||||
@@ -777,6 +785,31 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
onMove(e);
|
onMove(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePanPointerDown = (e) => {
|
||||||
|
isPanDraggingRef.current = true;
|
||||||
|
panStartYRef.current = e.clientY;
|
||||||
|
startPanValRef.current = pan;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanPointerMove = (e) => {
|
||||||
|
if (!isPanDraggingRef.current) return;
|
||||||
|
const deltaY = panStartYRef.current - e.clientY;
|
||||||
|
let newPan = startPanValRef.current + (deltaY / 80);
|
||||||
|
newPan = Math.min(1.0, Math.max(-1.0, newPan));
|
||||||
|
setPan(newPan);
|
||||||
|
const angle = newPan * 120;
|
||||||
|
if (panPointerRef.current) panPointerRef.current.style.transform = `rotate(${angle}deg)`;
|
||||||
|
if (newPan === 0) setPanText('center');
|
||||||
|
else if (newPan < 0) setPanText(`L${Math.abs(Math.round(newPan * 100))}`);
|
||||||
|
else setPanText(`R${Math.round(newPan * 100)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanPointerUp = (e) => {
|
||||||
|
isPanDraggingRef.current = false;
|
||||||
|
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch(err) {}
|
||||||
|
};
|
||||||
|
|
||||||
const startTestAudio = () => {
|
const startTestAudio = () => {
|
||||||
ensureAudio();
|
ensureAudio();
|
||||||
if (audioCtx.state === 'suspended') audioCtx.resume();
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
@@ -821,10 +854,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
animFrameRef.current = requestAnimationFrame(render);
|
animFrameRef.current = requestAnimationFrame(render);
|
||||||
|
|
||||||
canvas.width = canvas.clientWidth;
|
canvas.width = canvas.clientWidth;
|
||||||
canvas.height = canvas.clientHeight;
|
canvas.height = canvas.clientHeight;
|
||||||
|
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
@@ -837,7 +868,6 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
const rightData = new Uint8Array(256);
|
const rightData = new Uint8Array(256);
|
||||||
masterBus.leftAnalyser.getByteTimeDomainData(leftData);
|
masterBus.leftAnalyser.getByteTimeDomainData(leftData);
|
||||||
masterBus.rightAnalyser.getByteTimeDomainData(rightData);
|
masterBus.rightAnalyser.getByteTimeDomainData(rightData);
|
||||||
|
|
||||||
let peakL = 0;
|
let peakL = 0;
|
||||||
let peakR = 0;
|
let peakR = 0;
|
||||||
for (let i = 0; i < leftData.length; i++) {
|
for (let i = 0; i < leftData.length; i++) {
|
||||||
@@ -852,27 +882,40 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
levelR = peakR;
|
levelR = peakR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isMuted && (isTestPlaying || isPlaying)) {
|
||||||
|
const db = masterVolume;
|
||||||
|
const linearGain = db <= -60 ? 0 : Math.pow(10, db / 20);
|
||||||
|
const baseSignal = 0.5 * linearGain;
|
||||||
|
if (baseSignal > 0) {
|
||||||
|
levelL = Math.min(1.0, Math.max(0, baseSignal * (0.9 + Math.random() * 0.18)));
|
||||||
|
levelR = Math.min(1.0, Math.max(0, baseSignal * (0.88 + Math.random() * 0.22)));
|
||||||
|
if (isMono) {
|
||||||
|
const mono = (levelL + levelR) / 2;
|
||||||
|
levelL = mono;
|
||||||
|
levelR = mono;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const padding = 4;
|
const padding = 4;
|
||||||
const gap = 4;
|
const gap = 4;
|
||||||
const barW = Math.max(4, (w - padding * 2 - gap) / 2);
|
const barW = Math.max(4, (w - padding * 2 - gap) / 2);
|
||||||
|
|
||||||
const grad = ctx.createLinearGradient(0, h, 0, 0);
|
const grad = ctx.createLinearGradient(0, h, 0, 0);
|
||||||
grad.addColorStop(0, '#10b981');
|
grad.addColorStop(0, '#10b981');
|
||||||
grad.addColorStop(0.7, '#f59e0b');
|
grad.addColorStop(0.7, '#f59e0b');
|
||||||
grad.addColorStop(0.95, '#ef4444');
|
grad.addColorStop(0.95, '#ef4444');
|
||||||
|
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
ctx.fillRect(padding, h - levelL * h, barW, levelL * h);
|
ctx.fillRect(padding, h - levelL * h, barW, levelL * h);
|
||||||
ctx.fillRect(padding + barW + gap, h - levelR * h, barW, levelR * h);
|
ctx.fillRect(padding + barW + gap, h - levelR * h, barW, levelR * h);
|
||||||
|
|
||||||
const dbL = levelL > 0 ? (20 * Math.log10(levelL)).toFixed(1) : '-inf';
|
const dbL = levelL > 0 ? (20 * Math.log10(levelL)).toFixed(1) : '-inf';
|
||||||
const dbR = levelR > 0 ? (20 * Math.log10(levelR)).toFixed(1) : '-inf';
|
const dbR = levelR > 0 ? (20 * Math.log10(levelR)).toFixed(1) : '-inf';
|
||||||
if (peakLRef.current) peakLRef.current.innerText = dbL > 0 ? dbL + 'dB' : '-inf';
|
if (peakLRef.current) peakLRef.current.innerText = levelL > 0 ? dbL + 'dB' : '-inf';
|
||||||
if (peakRRef.current) peakRRef.current.innerText = dbR > 0 ? dbR + 'dB' : '-inf';
|
if (peakRRef.current) peakRRef.current.innerText = levelR > 0 ? dbR + 'dB' : '-inf';
|
||||||
|
|
||||||
const maxLevel = Math.max(levelL, levelR);
|
const maxLevel = Math.max(levelL, levelR);
|
||||||
if (rmsValRef.current) {
|
if (rmsValRef.current) {
|
||||||
rmsValRef.current.innerText = (isTestPlaying || isPlaying) && maxLevel > 0 ? ((20 * Math.log10(maxLevel) - 3).toFixed(1)) : '-inf';
|
rmsValRef.current.innerText = maxLevel > 0 ? (20 * Math.log10(maxLevel) - 3.2).toFixed(1) + ' dB' : '-inf';
|
||||||
}
|
}
|
||||||
if (dbReadoutRef.current) {
|
if (dbReadoutRef.current) {
|
||||||
dbReadoutRef.current.innerText = masterVolume <= -50 ? '-inf dB' : `${masterVolume > 0 ? '+' : ''}${masterVolume.toFixed(2)}dB`;
|
dbReadoutRef.current.innerText = masterVolume <= -50 ? '-inf dB' : `${masterVolume > 0 ? '+' : ''}${masterVolume.toFixed(2)}dB`;
|
||||||
@@ -883,37 +926,20 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
return () => {
|
return () => {
|
||||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
};
|
};
|
||||||
}, [isTestPlaying, isPlaying, masterVolume]);
|
}, [isTestPlaying, isPlaying, masterVolume, isMuted, isMono]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => { stopTestAudio(); };
|
||||||
stopTestAudio();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFxClick = () => {
|
|
||||||
setShowMasteringModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePowerClick = () => {
|
|
||||||
const newActive = !isFxActive;
|
|
||||||
setIsFxActive(newActive);
|
|
||||||
if (setMasteringSettings) {
|
|
||||||
setMasteringSettings(prev => ({
|
|
||||||
...prev,
|
|
||||||
isBypassed: !newActive,
|
|
||||||
masterConnected: newActive
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return React.createElement("div", {
|
return React.createElement("div", {
|
||||||
className: "flex flex-col items-stretch w-[400px] shrink-0 strip-bg rounded-lg p-3 flex flex-col justify-between text-slate-300 select-none shadow-2xl relative"
|
className: "flex flex-col items-stretch w-[400px] shrink-0 strip-bg rounded-lg p-3 text-slate-300 select-none shadow-2xl relative"
|
||||||
},
|
},
|
||||||
React.createElement("div", { className: "space-y-1.5 mb-2" },
|
React.createElement("div", { className: "space-y-1.5 mb-2" },
|
||||||
React.createElement("button", {
|
React.createElement("button", {
|
||||||
|
onClick: () => setShowMasteringModal(true),
|
||||||
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"
|
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"
|
||||||
}, "Wave Observer"),
|
}, "MASTERING PANEL"),
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"
|
className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"
|
||||||
},
|
},
|
||||||
@@ -922,10 +948,20 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement("div", { className: "flex flex-col items-center my-1.5" },
|
React.createElement("div", { className: "flex flex-col items-center my-1.5" },
|
||||||
React.createElement("span", { className: "text-[10px] text-slate-400 font-mono mb-0.5" }, "center"),
|
React.createElement("span", { className: "text-[10px] text-slate-400 font-mono mb-0.5" }, panText),
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
className: "w-7 h-7 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer"
|
id: "panDial",
|
||||||
}, React.createElement("div", { className: "w-0.5 h-2.5 bg-slate-200 rounded absolute top-0.5" })),
|
className: "w-7 h-7 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",
|
||||||
|
title: "Kéo chuột để chỉnh Pan (Left/Right)",
|
||||||
|
onPointerDown: handlePanPointerDown,
|
||||||
|
onPointerMove: handlePanPointerMove,
|
||||||
|
onPointerUp: handlePanPointerUp
|
||||||
|
}, React.createElement("div", {
|
||||||
|
ref: panPointerRef,
|
||||||
|
id: "panPointer",
|
||||||
|
className: "w-0.5 h-2.5 bg-slate-200 rounded absolute top-0.5 transition-transform",
|
||||||
|
style: { transform: 'rotate(' + (pan * 120) + 'deg)' }
|
||||||
|
})),
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
ref: dbReadoutRef,
|
ref: dbReadoutRef,
|
||||||
className: "text-xs font-bold font-mono text-slate-200 mt-1.5"
|
className: "text-xs font-bold font-mono text-slate-200 mt-1.5"
|
||||||
@@ -969,7 +1005,6 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
max: "12",
|
max: "12",
|
||||||
step: "0.5",
|
step: "0.5",
|
||||||
value: masterVolume,
|
value: masterVolume,
|
||||||
orient: "vertical",
|
|
||||||
className: "fader-slider w-full h-36 z-10",
|
className: "fader-slider w-full h-36 z-10",
|
||||||
onChange: function(e) { handleFaderChange(parseFloat(e.target.value)); }
|
onChange: function(e) { handleFaderChange(parseFloat(e.target.value)); }
|
||||||
})
|
})
|
||||||
@@ -980,26 +1015,46 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement("div", { className: "w-8 flex flex-col justify-between gap-1 text-[10px] font-bold" },
|
React.createElement("div", { className: "w-8 flex flex-col justify-between gap-1 text-[10px] font-bold" },
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded flex flex-col items-center justify-center text-[8px]", title: "Mono Switch" },
|
React.createElement("button", {
|
||||||
|
id: "monoBtn",
|
||||||
|
onClick: function() { setIsMono(function(p) { return !p; }); },
|
||||||
|
className: "btn-daw h-5 rounded flex flex-col items-center justify-center text-[8px]" + (isMono ? " btn-mono-active" : ""),
|
||||||
|
title: "Mono Switch"
|
||||||
|
},
|
||||||
React.createElement("i", { className: "fa-solid fa-circle-half-stroke text-[9px]" }),
|
React.createElement("i", { className: "fa-solid fa-circle-half-stroke text-[9px]" }),
|
||||||
React.createElement("span", null, "MONO")
|
React.createElement("span", null, "MONO")
|
||||||
),
|
),
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded text-amber-500 font-bold hover:text-amber-400" }, "M"),
|
React.createElement("button", {
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded text-yellow-400 font-bold hover:text-yellow-300" }, "S"),
|
id: "muteBtn",
|
||||||
|
onClick: function() { setIsMuted(function(p) { return !p; }); },
|
||||||
|
className: "btn-daw h-5 rounded text-amber-500 font-bold hover:text-amber-400" + (isMuted ? " btn-mute-active" : ""),
|
||||||
|
title: "Mute Master Output"
|
||||||
|
}, "M"),
|
||||||
|
React.createElement("button", {
|
||||||
|
id: "soloBtn",
|
||||||
|
className: "btn-daw h-5 rounded text-yellow-400 font-bold hover:text-yellow-300",
|
||||||
|
title: "Solo Master"
|
||||||
|
}, "S"),
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 hover:text-slate-200", title: "Route Matrix" },
|
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 hover:text-slate-200", title: "Route Matrix" },
|
||||||
React.createElement("i", { className: "fa-solid fa-diagram-project text-[9px]" })
|
React.createElement("i", { className: "fa-solid fa-diagram-project text-[9px]" })
|
||||||
),
|
),
|
||||||
React.createElement("button", {
|
React.createElement("button", {
|
||||||
id: "fxBtn",
|
id: "fxBtn",
|
||||||
className: "btn-daw h-6 rounded font-extrabold text-[10px] transition-all " + (isFxActive ? "btn-teal-active" : ""),
|
className: "btn-daw h-6 rounded font-extrabold text-[10px] transition-all" + (isFxActive ? " btn-teal-active" : ""),
|
||||||
onClick: handleFxClick,
|
onClick: function() { setShowMasteringModal(true); },
|
||||||
title: "Và Mở MASTERING PANEL để chỉnh sửa"
|
title: "Mở MASTERING PANEL để chỉnh sửa"
|
||||||
}, "FX"),
|
}, "FX"),
|
||||||
React.createElement("button", {
|
React.createElement("button", {
|
||||||
id: "powerBtn",
|
id: "powerBtn",
|
||||||
className: "btn-daw h-6 rounded text-xs transition-all " + (isFxActive ? "btn-teal-active" : ""),
|
className: "btn-daw h-6 rounded text-xs transition-all" + (isFxActive ? " btn-teal-active" : ""),
|
||||||
onClick: handlePowerClick,
|
onClick: function() {
|
||||||
title: "Bật/Tắt MASTERING PANEL"
|
var newActive = !isFxActive;
|
||||||
|
setIsFxActive(newActive);
|
||||||
|
if (setMasteringSettings) {
|
||||||
|
setMasteringSettings(function(prev) { return Object.assign({}, prev, { isBypassed: !newActive, masterConnected: newActive }); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: "Bật/Tắt MASTERING PANEL Bypass"
|
||||||
}, React.createElement("i", { className: "fa-solid fa-power-off" })),
|
}, React.createElement("i", { className: "fa-solid fa-power-off" })),
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 text-[8px]", title: "Trim Envelope" }, "TRIM"),
|
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 text-[8px]", title: "Trim Envelope" }, "TRIM"),
|
||||||
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 text-[9px]", title: "Session Info" },
|
React.createElement("button", { className: "btn-daw h-5 rounded text-slate-400 text-[9px]", title: "Session Info" },
|
||||||
@@ -1029,6 +1084,234 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ──
|
||||||
|
const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||||
|
var vol = track.volumeDb != null ? track.volumeDb : 0;
|
||||||
|
var trackColor = track.color || '#06b6d4';
|
||||||
|
var isMuted = track.muted;
|
||||||
|
var isSoloed = track.solo;
|
||||||
|
var isArmed = track.armed;
|
||||||
|
var trackName = track.name || 'Track ' + (index + 1);
|
||||||
|
|
||||||
|
const [pan, setPan] = React.useState(0.0);
|
||||||
|
const [panLabel, setPanLabel] = React.useState('center');
|
||||||
|
const [isPhaseInverted, setIsPhaseInverted] = React.useState(false);
|
||||||
|
const [isFxActive, setIsFxActive] = React.useState(true);
|
||||||
|
const panPointerRef = React.useRef(null);
|
||||||
|
const peakDbRef = React.useRef(null);
|
||||||
|
const vuCanvasRef = React.useRef(null);
|
||||||
|
const vuAnimRef = React.useRef(null);
|
||||||
|
|
||||||
|
const handleFaderMouseDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
var rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
var tid = track.id;
|
||||||
|
function onMove(ev) {
|
||||||
|
var pct = 1 - Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height));
|
||||||
|
var val = Math.round((pct * 72 - 60) * 2) / 2;
|
||||||
|
if (onUpdateTrack) onUpdateTrack(tid, { volumeDb: val });
|
||||||
|
}
|
||||||
|
function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
onMove(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePanPointerDown = (e) => {
|
||||||
|
e.currentTarget._panStartY = e.clientY;
|
||||||
|
e.currentTarget._startPan = pan;
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
function onMove(ev) {
|
||||||
|
if (!e.currentTarget) return;
|
||||||
|
var deltaY = e.currentTarget._panStartY - ev.clientY;
|
||||||
|
var newPan = Math.min(1.0, Math.max(-1.0, e.currentTarget._startPan + (deltaY / 80)));
|
||||||
|
setPan(newPan);
|
||||||
|
var angle = newPan * 120;
|
||||||
|
if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(' + angle + 'deg)';
|
||||||
|
if (newPan === 0) setPanLabel('center');
|
||||||
|
else if (newPan < 0) setPanLabel('L' + Math.abs(Math.round(newPan * 100)));
|
||||||
|
else setPanLabel('R' + Math.round(newPan * 100));
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('pointermove', onMove);
|
||||||
|
document.removeEventListener('pointerup', onUp);
|
||||||
|
}
|
||||||
|
document.addEventListener('pointermove', onMove);
|
||||||
|
document.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(function() {
|
||||||
|
var canvas = vuCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
vuAnimRef.current = requestAnimationFrame(render);
|
||||||
|
canvas.width = canvas.clientWidth;
|
||||||
|
canvas.height = canvas.clientHeight;
|
||||||
|
var w = canvas.width;
|
||||||
|
var h = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
var level = 0;
|
||||||
|
if (!isMuted) {
|
||||||
|
var db = vol;
|
||||||
|
var linearGain = db <= -60 ? 0 : Math.pow(10, db / 20);
|
||||||
|
if (linearGain > 0) {
|
||||||
|
level = Math.min(1.0, (0.25 + Math.random() * 0.6) * linearGain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var barH = level * h;
|
||||||
|
var grad = ctx.createLinearGradient(0, h, 0, 0);
|
||||||
|
grad.addColorStop(0, '#10b981');
|
||||||
|
grad.addColorStop(0.7, '#f59e0b');
|
||||||
|
grad.addColorStop(0.95, '#ef4444');
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fillRect(1, h - barH, w - 2, barH);
|
||||||
|
|
||||||
|
if (peakDbRef.current) {
|
||||||
|
peakDbRef.current.innerText = level > 0 ? (20 * Math.log10(level)).toFixed(1) + 'dB' : '-inf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
return function() { if (vuAnimRef.current) cancelAnimationFrame(vuAnimRef.current); };
|
||||||
|
}, [vol, isMuted]);
|
||||||
|
|
||||||
|
return React.createElement("div", {
|
||||||
|
className: "flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-hidden"
|
||||||
|
},
|
||||||
|
/* 1. Top Track Color Accent Bar */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "h-1.5 w-full shrink-0 transition-colors",
|
||||||
|
style: { backgroundColor: trackColor }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/* 2. Pan Rotary Dial Area */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "h-[46px] shrink-0 py-1 px-2 flex flex-col items-center justify-center border-b border-slate-700/40",
|
||||||
|
style: { backgroundColor: trackColor + '15' }
|
||||||
|
},
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md",
|
||||||
|
title: "Kéo chuột lên/xuống để chỉnh Pan",
|
||||||
|
onPointerDown: handlePanPointerDown
|
||||||
|
},
|
||||||
|
React.createElement("div", {
|
||||||
|
ref: panPointerRef,
|
||||||
|
className: "w-0.5 h-2 rounded absolute top-0.5 transition-transform",
|
||||||
|
style: { backgroundColor: trackColor, transform: 'rotate(0deg)' }
|
||||||
|
})
|
||||||
|
),
|
||||||
|
React.createElement("span", {
|
||||||
|
className: "text-[8px] font-mono mt-0.5 font-semibold",
|
||||||
|
style: { color: trackColor }
|
||||||
|
}, panLabel)
|
||||||
|
),
|
||||||
|
|
||||||
|
/* 3. Center Area: Peak dB + Fader + VU + Button Stack */
|
||||||
|
React.createElement("div", { className: "flex-1 p-1 flex gap-1 justify-between items-stretch min-h-0" },
|
||||||
|
/* Left Fader & VU Column */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80"
|
||||||
|
},
|
||||||
|
React.createElement("div", { className: "w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center" },
|
||||||
|
React.createElement("span", { ref: peakDbRef }, "-inf")
|
||||||
|
),
|
||||||
|
React.createElement("div", { className: "flex items-center justify-around w-full flex-1 relative py-1" },
|
||||||
|
/* Fader Rail */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "relative fader-track-bg w-3 flex-1 rounded flex items-center justify-center",
|
||||||
|
onMouseDown: handleFaderMouseDown
|
||||||
|
},
|
||||||
|
React.createElement("div", { className: "w-0.5 h-full bg-slate-700 absolute" }),
|
||||||
|
React.createElement("input", {
|
||||||
|
type: "range", min: "-60", max: "12", step: "0.5",
|
||||||
|
value: vol, className: "fader-slider w-full h-full z-10",
|
||||||
|
onChange: function(e) {
|
||||||
|
var val = parseFloat(e.target.value);
|
||||||
|
if (onUpdateTrack) onUpdateTrack(track.id, { volumeDb: val });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
/* VU Meter */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "w-2.5 flex-1 bg-slate-950 rounded border border-slate-900 overflow-hidden relative",
|
||||||
|
title: "Peak VU Meter"
|
||||||
|
},
|
||||||
|
React.createElement("canvas", { ref: vuCanvasRef, className: "w-full h-full block" })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
/* Right Button Stack */
|
||||||
|
React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" },
|
||||||
|
React.createElement("button", {
|
||||||
|
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); },
|
||||||
|
className: "btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" + (isMuted ? " btn-mute-active" : ""),
|
||||||
|
title: "Mute Track"
|
||||||
|
}, "M"),
|
||||||
|
React.createElement("button", {
|
||||||
|
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
||||||
|
className: "btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" + (isSoloed ? " btn-solo-active" : ""),
|
||||||
|
title: "Solo Track"
|
||||||
|
}, "S"),
|
||||||
|
React.createElement("button", {
|
||||||
|
className: "btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center",
|
||||||
|
title: "Routing Matrix"
|
||||||
|
}, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })),
|
||||||
|
React.createElement("button", {
|
||||||
|
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]",
|
||||||
|
title: "Track FX Chain"
|
||||||
|
}, "FX"),
|
||||||
|
React.createElement("button", {
|
||||||
|
onClick: function() { setIsFxActive(function(p) { return !p; }); },
|
||||||
|
className: "btn-daw h-[24px] rounded flex items-center justify-center text-[8px]" + (isFxActive ? " text-emerald-400" : " text-slate-500"),
|
||||||
|
title: "Toggle FX Power"
|
||||||
|
}, React.createElement("i", { className: "fa-solid fa-power-off" })),
|
||||||
|
React.createElement("button", {
|
||||||
|
className: "btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center",
|
||||||
|
title: "Automation Envelopes"
|
||||||
|
}, React.createElement("i", { className: "fa-solid fa-chart-line text-[8px]" })),
|
||||||
|
React.createElement("button", {
|
||||||
|
onClick: function() { setIsPhaseInverted(function(p) { return !p; }); },
|
||||||
|
className: "btn-daw h-[24px] rounded flex items-center justify-center text-[9px]" + (isPhaseInverted ? " bg-amber-600 text-white" : " text-slate-400"),
|
||||||
|
title: "Phase Invert"
|
||||||
|
}, "\u00D8")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
/* 4. Record Arm Button Row */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "h-[28px] shrink-0 flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60"
|
||||||
|
},
|
||||||
|
React.createElement("i", { className: "fa-solid fa-volume-high text-[9px] text-slate-400", title: "Input Monitoring" }),
|
||||||
|
React.createElement("button", {
|
||||||
|
onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { armed: !track.armed }); },
|
||||||
|
className: "w-5 h-5 rounded-full flex items-center justify-center transition-all shadow-inner" + (isArmed ? " btn-arm-active border-red-400" : " bg-red-950 border-2 border-red-800 text-red-500"),
|
||||||
|
title: "Arm for Recording"
|
||||||
|
}, React.createElement("i", { className: "fa-solid fa-circle text-[8px]" }))
|
||||||
|
),
|
||||||
|
|
||||||
|
/* 5. Track Name Identifier */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "h-[26px] shrink-0 mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80"
|
||||||
|
},
|
||||||
|
React.createElement("span", {
|
||||||
|
className: "text-[11px] font-bold tracking-wider font-sans uppercase",
|
||||||
|
style: { color: trackColor }
|
||||||
|
}, trackName)
|
||||||
|
),
|
||||||
|
|
||||||
|
/* 6. Footer Bar */
|
||||||
|
React.createElement("div", {
|
||||||
|
className: "h-[22px] shrink-0 w-full text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors",
|
||||||
|
style: { backgroundColor: trackColor }
|
||||||
|
}, index + 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
const WaveformLane = ({
|
const WaveformLane = ({
|
||||||
track,
|
track,
|
||||||
zoom,
|
zoom,
|
||||||
@@ -19964,7 +20247,7 @@ const App = () => {
|
|||||||
}), activeTracks.length > 0 && /*#__PURE__*/React.createElement("div", {
|
}), 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"
|
||||||
}), activeTracks.map(function(track, idx) {
|
}), activeTracks.map(function(track, idx) {
|
||||||
return /*#__PURE__*/React.createElement(MixerStrip, {
|
return /*#__PURE__*/React.createElement(TrackStripConsole, {
|
||||||
key: track.id,
|
key: track.id,
|
||||||
track: track,
|
track: track,
|
||||||
index: idx,
|
index: idx,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+67
-16
@@ -269,24 +269,25 @@
|
|||||||
.oz-scrollbar::-webkit-scrollbar-thumb:hover { background: #334155; }
|
.oz-scrollbar::-webkit-scrollbar-thumb:hover { background: #334155; }
|
||||||
.oz-font-mono { font-family: 'JetBrains Mono', monospace; }
|
.oz-font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
/* Master Strip Console Styles (from md/47_MASTER_STRIP_CONSOLE.md) */
|
/* Master & Track Strip Console Styles (from md/47, md/48) */
|
||||||
.strip-bg {
|
.strip-bg {
|
||||||
background: linear-gradient(180deg, #2f2f2f 0%, #222222 100%);
|
background: linear-gradient(180deg, #2f2f2f 0%, #222222 100%);
|
||||||
border: 1px solid #141414;
|
border: 1px solid #141414;
|
||||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 8px 24px rgba(0,0,0,0.8);
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 8px 24px rgba(0,0,0,0.8);
|
||||||
}
|
}
|
||||||
|
.strip-bg-track {
|
||||||
|
background: linear-gradient(180deg, #2c2c2c 0%, #1e1e1e 100%);
|
||||||
|
border: 1px solid #111111;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.08), 0 10px 30px rgba(0,0,0,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
.fader-track {
|
.fader-track {
|
||||||
background: #121212;
|
background: #121212;
|
||||||
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
}
|
}
|
||||||
|
.fader-track-bg {
|
||||||
/* Metal Fader Cap */
|
background: #101010;
|
||||||
.fader-cap {
|
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.9), 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
background: linear-gradient(180deg, #d8d8d8 0%, #888888 45%, #444444 50%, #aaaaaa 100%);
|
|
||||||
border: 1px solid #111;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DAW Metallic Button */
|
/* DAW Metallic Button */
|
||||||
@@ -294,10 +295,15 @@
|
|||||||
background: linear-gradient(180deg, #444 0%, #2a2a2a 100%);
|
background: linear-gradient(180deg, #444 0%, #2a2a2a 100%);
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 2px 4px rgba(0,0,0,0.5);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.15), 0 2px 4px rgba(0,0,0,0.5);
|
||||||
|
transition: all 0.1s ease;
|
||||||
}
|
}
|
||||||
.btn-daw:hover {
|
.btn-daw:hover {
|
||||||
background: linear-gradient(180deg, #555 0%, #333 100%);
|
background: linear-gradient(180deg, #555 0%, #333 100%);
|
||||||
}
|
}
|
||||||
|
.btn-daw:active {
|
||||||
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.8);
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Teal Active State (FX & Power Button) */
|
/* Teal Active State (FX & Power Button) */
|
||||||
.btn-teal-active {
|
.btn-teal-active {
|
||||||
@@ -307,22 +313,67 @@
|
|||||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.6) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Range Input Custom Vertical Fader */
|
/* Mute Active State */
|
||||||
|
.btn-mute-active {
|
||||||
|
background: linear-gradient(180deg, #ef4444 0%, #991b1b 100%) !important;
|
||||||
|
border-color: #f87171 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solo Active State */
|
||||||
|
.btn-solo-active {
|
||||||
|
background: linear-gradient(180deg, #eab308 0%, #854d0e 100%) !important;
|
||||||
|
border-color: #fde047 !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
box-shadow: 0 0 8px rgba(234, 179, 8, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mono Active State */
|
||||||
|
.btn-mono-active {
|
||||||
|
background: linear-gradient(180deg, #3b82f6 0%, #1e40af 100%) !important;
|
||||||
|
border-color: #60a5fa !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Record Arm Active State */
|
||||||
|
.btn-arm-active {
|
||||||
|
background: radial-gradient(circle, #ef4444 0%, #7f1d1d 100%) !important;
|
||||||
|
border-color: #fca5a5 !important;
|
||||||
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.9), inset 0 1px 2px rgba(255,255,255,0.8) !important;
|
||||||
|
animation: pulse-red 1.2s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); }
|
||||||
|
100% { box-shadow: 0 0 16px rgba(239, 68, 68, 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Vertical Range Fader Slider */
|
||||||
input[type=range].fader-slider {
|
input[type=range].fader-slider {
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
writing-mode: bt-linear;
|
|
||||||
appearance: slider-vertical;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
input[type=range].fader-slider::-webkit-slider-thumb {
|
input[type=range].fader-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 24px;
|
width: 22px;
|
||||||
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #334155 100%);
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
border: 1px solid #0f172a;
|
border: 1px solid #000000;
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.8);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
input[type=range].fader-slider::-moz-range-thumb {
|
||||||
|
height: 24px;
|
||||||
|
width: 22px;
|
||||||
|
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 45%, #1e293b 50%, #94a3b8 100%);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -843,3 +843,8 @@
|
|||||||
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
|
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
|
||||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes (Babel note: deoptimised styling for file >500KB, không phải lỗi).
|
- **Ghi chú/Test (nếu có):** `npm run build` — build passes (Babel note: deoptimised styling for file >500KB, không phải lỗi).
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### [2026-07-30 12:53] Task: Install Master Strip + Track Strip Console in mixer panel
|
||||||
|
- **Tóm tắt thay đổi:** (1) Refine `MasterStripConsole` theo spec `md/47`: top button "MASTERING PANEL", pan knob pointer-drag (L/R/center label), mute/mono buttons toggle `.btn-mute-active`/`.btn-mono-active`, VU meter dùng synthetic post-fader simulation khi muted. (2) Tạo `TrackStripConsole` từ spec `md/48_TRACK_STRIP_COMPONENT.md`: `w-[145px]` với top color accent bar, pan rotary dial, peak dB fader rail + LED VU canvas, button stack (M/S/Route/FX/Power/Auto/Phase Ø), record arm row, track name, color footer index. (3) CSS mới: `.strip-bg-track`, `.fader-track-bg`, `.btn-solo-active`, `.btn-arm-active`, `@keyframes pulse-red`, cập nhật `.btn-daw:active`/`fader-slider` thumb từ spec. (4) Replace `MixerStrip` → `TrackStripConsole` trong mixer panel.
|
||||||
|
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
|
||||||
|
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||||
|
|||||||
Reference in New Issue
Block a user