FIX: sửa MASTERING PANEL thay đổi knob thì thay đổi các thông số hiển thị

This commit is contained in:
2026-07-30 11:18:35 +07:00
parent 6e1f116658
commit eb61015cff
3 changed files with 532 additions and 106 deletions
+468 -101
View File
@@ -226,6 +226,16 @@ function initMasterBus(ctx) {
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
const leftAnalyser = ctx.createAnalyser();
leftAnalyser.fftSize = 2048;
const rightAnalyser = ctx.createAnalyser();
rightAnalyser.fftSize = 2048;
const splitter = ctx.createChannelSplitter(2);
output.connect(splitter);
splitter.connect(leftAnalyser, 0);
splitter.connect(rightAnalyser, 1);
masterBus = {
input: ctx.createGain(),
compressor: ctx.createDynamicsCompressor(),
@@ -236,6 +246,8 @@ function initMasterBus(ctx) {
// Analysers for metering
inputAnalyser,
outputAnalyser,
leftAnalyser,
rightAnalyser,
// Mastering nodes
eqLowFilter, eqMid1Filter, eqMid2Filter, eqHighFilter,
@@ -6114,6 +6126,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
e.stopPropagation();
keybedMouseDownRef.current = true;
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, st.instrumentProgram, null, kbCh, kbSynth);
}
@@ -6124,6 +6139,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
onMouseEnter: (e) => {
if (keybedMouseDownRef.current) {
try {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(st.trackId, 100);
}
if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, st.instrumentProgram, null, kbCh, kbSynth);
}
@@ -7016,6 +7034,79 @@ const deserializeProjectFromSchema = (schemaObj) => {
};
};
//
// MASTERING KNOB COMPONENT (Dynamic pointer events version)
//
const MasteringKnob = ({ param, min, max, value, unit, label, color, onChange, size = 'small' }) => {
const [isDragging, setIsDragging] = React.useState(false);
const startYRef = React.useRef(0);
const startValRef = React.useRef(0);
const handlePointerDown = (e) => {
e.preventDefault();
setIsDragging(true);
startYRef.current = e.clientY;
startValRef.current = value;
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e) => {
if (!isDragging) return;
const deltaY = startYRef.current - e.clientY;
let newVal = startValRef.current + (deltaY / 150) * (max - min);
newVal = Math.min(max, Math.max(min, newVal));
onChange(param, newVal);
};
const handlePointerUp = (e) => {
setIsDragging(false);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
};
const pct = (value - min) / (max - min);
const angle = -135 + pct * 270;
const isLarge = size === 'large';
const dialClass = isLarge ? 'w-20 h-20 border-4 bg-slate-900' : 'w-10 h-10 border-2 bg-slate-800';
const pointerHeight = isLarge ? 'h-6' : 'h-3';
const valClass = isLarge ? 'text-xs text-cyan-300 font-bold mt-2 z-10' : 'text-[9px] text-slate-300 font-mono mt-1 font-bold';
return (
<div className="flex flex-col items-center select-none">
{label && <span className="text-[10px] font-bold text-slate-400 mb-1.5 uppercase tracking-wide">{label}</span>}
<div
className={`${dialClass} rounded-full relative flex items-center justify-center cursor-ns-resize shadow-lg`}
style={{ borderColor: color }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div
className="w-0.5 absolute rounded origin-bottom"
style={{
backgroundColor: color,
height: isLarge ? '22px' : '12px',
top: isLarge ? '6px' : '4px',
transform: `rotate(${angle}deg)`,
transformOrigin: '50% 100%'
}}
></div>
{isLarge && (
<span className="text-[10px] font-bold text-cyan-300 z-10 bg-slate-950/80 px-1 py-0.5 rounded border border-slate-800">
{value > 0 && unit === 'dB' ? '+' : ''}{value.toFixed(1)} {unit}
</span>
)}
</div>
{!isLarge && (
<span className={valClass}>
{value > 0 && unit === 'dB' ? '+' : ''}{value.toFixed(1)}{unit}
</span>
)}
</div>
);
};
//
// MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md)
//
@@ -7039,6 +7130,30 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const animFrameRef = React.useRef(null);
const knobsInitializedRef = React.useRef(false);
// Wave Observer Refs & States
const woCanvasRef = React.useRef(null);
const woLeftHistoryRef = React.useRef(new Float32Array(400).fill(0));
const woRightHistoryRef = React.useRef(new Float32Array(400).fill(0));
const woLeftMeterRef = React.useRef(null);
const woRightMeterRef = React.useRef(null);
const [woPaused, setWoPaused] = React.useState(false);
const [woChannel, setWoChannel] = React.useState('stereo');
const [woMode, setWoMode] = React.useState('waveform');
const [woDuration, setWoDuration] = React.useState(2.0);
const [woZoom, setWoZoom] = React.useState(0.0);
const woPausedRef = React.useRef(woPaused);
woPausedRef.current = woPaused;
const woChannelRef = React.useRef(woChannel);
woChannelRef.current = woChannel;
const woModeRef = React.useRef(woMode);
woModeRef.current = woMode;
const woDurationRef = React.useRef(woDuration);
woDurationRef.current = woDuration;
const woZoomRef = React.useRef(woZoom);
woZoomRef.current = woZoom;
const ozStateRef = React.useRef(ozState);
ozStateRef.current = ozState;
@@ -7097,6 +7212,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
resizeCanvas(imagerCanvasRef);
resizeCanvas(inMeterCanvasRef);
resizeCanvas(outMeterCanvasRef);
resizeCanvas(woCanvasRef);
}
resizeAll();
window.addEventListener('resize', resizeAll);
@@ -7223,61 +7339,163 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
};
renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText');
renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText');
// Wave Observer Oscilloscope Rendering
const woCanvas = woCanvasRef.current;
if (woCanvas) {
const w = woCanvas.width, h = woCanvas.height;
const woCtx = woCanvas.getContext('2d');
woCtx.clearRect(0, 0, w, h);
// Draw grid
woCtx.strokeStyle = 'rgba(51, 65, 85, 0.2)';
woCtx.lineWidth = 1;
woCtx.font = '8px JetBrains Mono, monospace';
woCtx.fillStyle = '#475569';
const centerY = h / 2;
const gridLines = [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75];
gridLines.forEach(g => {
const y = centerY + g * centerY;
woCtx.beginPath();
woCtx.moveTo(0, y);
woCtx.lineTo(w, y);
woCtx.stroke();
});
// Vertical lines
const ticksCount = 10;
for (let i = 1; i <= ticksCount; i++) {
const x = (i / (ticksCount + 1)) * w;
woCtx.beginPath();
woCtx.moveTo(x, 0);
woCtx.lineTo(x, h);
woCtx.stroke();
}
// dB labels on left side
woCtx.fillText('-6.0 dB', 5, centerY - 0.5 * centerY + 3);
woCtx.fillText('-9.0 dB', 5, centerY - 0.35 * centerY + 3);
woCtx.fillText('-15.0 dB', 5, centerY - 0.18 * centerY + 3);
woCtx.fillText('-27.0 dB', 5, centerY - 0.05 * centerY + 3);
woCtx.fillText('-27.0 dB', 5, centerY + 0.05 * centerY + 3);
woCtx.fillText('-15.0 dB', 5, centerY + 0.18 * centerY + 3);
woCtx.fillText('-9.0 dB', 5, centerY + 0.35 * centerY + 3);
woCtx.fillText('-6.0 dB', 5, centerY + 0.5 * centerY + 3);
// Time indicators at the bottom
const durationSec = woDurationRef.current;
for (let i = 1; i <= 5; i++) {
const timeVal = (i / 6) * durationSec;
const x = (i / 6) * w;
woCtx.fillText(timeVal.toFixed(2) + 's', x - 10, h - 4);
}
let leftPeak = 0;
let rightPeak = 0;
if (!woPausedRef.current && masterBus && masterBus.leftAnalyser && masterBus.rightAnalyser) {
const leftData = new Float32Array(512);
const rightData = new Float32Array(512);
masterBus.leftAnalyser.getFloatTimeDomainData(leftData);
masterBus.rightAnalyser.getFloatTimeDomainData(rightData);
for (let i = 0; i < 512; i++) {
const l = Math.abs(leftData[i]);
const r = Math.abs(rightData[i]);
if (l > leftPeak) leftPeak = l;
if (r > rightPeak) rightPeak = r;
}
const lHistory = woLeftHistoryRef.current;
const rHistory = woRightHistoryRef.current;
// Shift history buffer Left
for (let i = 0; i < lHistory.length - 1; i++) {
lHistory[i] = lHistory[i + 1];
rHistory[i] = rHistory[i + 1];
}
if (woModeRef.current === 'envelope') {
lHistory[lHistory.length - 1] = leftPeak;
rHistory[rHistory.length - 1] = rightPeak;
} else {
lHistory[lHistory.length - 1] = leftData[0];
rHistory[rHistory.length - 1] = rightData[0];
}
}
const lHistory = woLeftHistoryRef.current;
const rHistory = woRightHistoryRef.current;
const zoomGain = Math.pow(10, woZoomRef.current / 20);
// Update input meter bars
if (woLeftMeterRef.current && woRightMeterRef.current) {
const lPct = Math.min(100, leftPeak * 100);
const rPct = Math.min(100, rightPeak * 100);
woLeftMeterRef.current.style.width = `${lPct}%`;
woRightMeterRef.current.style.width = `${rPct}%`;
}
// Left channel line (cyan)
if (woChannelRef.current === 'stereo' || woChannelRef.current === 'left') {
woCtx.strokeStyle = '#22d3ee';
woCtx.lineWidth = 1.2;
woCtx.beginPath();
for (let i = 0; i < lHistory.length; i++) {
const x = (i / (lHistory.length - 1)) * w;
const val = lHistory[i] * zoomGain;
const y = centerY - val * centerY;
if (i === 0) woCtx.moveTo(x, y);
else woCtx.lineTo(x, y);
}
woCtx.stroke();
if (woModeRef.current === 'envelope') {
woCtx.beginPath();
for (let i = 0; i < lHistory.length; i++) {
const x = (i / (lHistory.length - 1)) * w;
const val = lHistory[i] * zoomGain;
const y = centerY + val * centerY;
if (i === 0) woCtx.moveTo(x, y);
else woCtx.lineTo(x, y);
}
woCtx.stroke();
}
}
// Right channel line (teal)
if (woChannelRef.current === 'stereo' || woChannelRef.current === 'right') {
woCtx.strokeStyle = '#0d9488';
woCtx.lineWidth = 1.2;
woCtx.beginPath();
for (let i = 0; i < rHistory.length; i++) {
const x = (i / (rHistory.length - 1)) * w;
const val = rHistory[i] * zoomGain;
const y = centerY - val * centerY;
if (i === 0) woCtx.moveTo(x, y);
else woCtx.lineTo(x, y);
}
woCtx.stroke();
if (woModeRef.current === 'envelope') {
woCtx.beginPath();
for (let i = 0; i < rHistory.length; i++) {
const x = (i / (rHistory.length - 1)) * w;
const val = rHistory[i] * zoomGain;
const y = centerY + val * centerY;
if (i === 0) woCtx.moveTo(x, y);
else woCtx.lineTo(x, y);
}
woCtx.stroke();
}
}
}
}
renderFrame();
// Knob setup
if (!knobsInitializedRef.current) {
knobsInitializedRef.current = true;
const container = document.getElementById('masteringModalBody');
if (container) {
container.querySelectorAll('.knob-container').forEach(knob => {
let isDragging = false, startY = 0, startVal = 0;
const param = knob.dataset.param;
if (!param) return;
const min = parseFloat(knob.dataset.min);
const max = parseFloat(knob.dataset.max);
const unit = knob.dataset.unit || '';
const dial = knob.querySelector('.knob-dial');
const valText = knob.querySelector('.knob-val');
function updateUI(val) {
const pct = (val - min) / (max - min);
const angle = -135 + pct * 270;
if (dial) dial.style.transform = `rotate(${angle}deg)`;
if (valText) valText.innerText = `${val > 0 && unit === 'dB' ? '+' : ''}${val.toFixed(1)} ${unit}`;
setOzState(prev => ({ ...prev, [param]: val }));
}
const startValParsed = parseFloat(knob.dataset.value);
updateUI(startValParsed);
knob.addEventListener('pointerdown', e => {
isDragging = true;
startY = e.clientY;
startVal = ozStateRef.current[param] !== undefined ? ozStateRef.current[param] : startValParsed;
knob.setPointerCapture(e.pointerId);
});
knob.addEventListener('pointermove', e => {
if (!isDragging) return;
const deltaY = startY - e.clientY;
let newVal = startVal + (deltaY / 100) * (max - min);
newVal = Math.min(max, Math.max(min, newVal));
updateUI(newVal);
});
knob.addEventListener('pointerup', e => {
isDragging = false;
knob.releasePointerCapture(e.pointerId);
});
knob.addEventListener('pointercancel', e => {
isDragging = false;
knob.releasePointerCapture(e.pointerId);
});
});
}
}
return () => {
window.removeEventListener('resize', resizeAll);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
@@ -7298,22 +7516,23 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name }));
const bandKnob = (param, min, max, val, unit, label, freq, color, filterType) => (
<div className="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
<div className="flex items-center justify-between text-xs font-bold" style={{color}}>
<div className="bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg">
<div className="flex items-center justify-between text-[10px] font-bold w-full" style={{color}}>
<span>{label}</span>
<span className="text-[10px] font-mono text-slate-400">{freq}</span>
<span className="text-[9px] font-mono text-slate-400">{freq}</span>
</div>
<div className="flex items-center justify-around my-2">
<div className="knob-container" data-param={param} data-min={min} data-max={max} data-value={val} data-unit={unit}>
<div className="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 relative flex items-center justify-center shadow-md" style={{borderColor: color}}>
<div className="knob-pointer w-0.5 h-3 rounded absolute top-1" style={{backgroundColor: color}}></div>
</div>
</div>
<div className="text-center font-mono">
<span className="knob-val text-xs text-slate-200 font-bold">{val > 0 && unit === 'dB' ? '+' : ''}{val.toFixed(1)} {unit}</span>
</div>
<div className="my-1">
<MasteringKnob
param={param}
min={min}
max={max}
value={ozState[param] !== undefined ? ozState[param] : val}
unit={unit}
color={color}
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
/>
</div>
<div className="text-[9px] text-slate-500 font-mono text-center">{filterType}</div>
<div className="text-[9px] text-slate-500 font-mono text-center mt-1">{filterType}</div>
</div>
);
@@ -7489,49 +7708,156 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
<span className="text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3">Maximizer Gain Boost</span>
<div className="knob-container my-2" data-param="maxGain" data-min="0" data-max="12" data-value="5.4" data-unit="dB">
<div className="knob-dial w-24 h-24 rounded-full bg-slate-900 border-4 border-cyan-500 relative flex items-center justify-center shadow-2xl">
<div className="knob-pointer w-1 h-8 bg-cyan-400 rounded absolute top-2"></div>
<span className="knob-val text-sm font-bold oz-font-mono text-cyan-300 z-10">+5.4 dB</span>
</div>
<div className="my-2">
<MasteringKnob
param="maxGain"
min={0}
max={12}
value={ozState.maxGain}
unit="dB"
color="#22d3ee"
size="large"
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
/>
</div>
<div className="w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex justify-between items-center">
<span className="text-slate-400">Ceiling Level:</span>
<span className="text-cyan-400 font-bold">{ozState.ceiling.toFixed(2)} dB</span>
<div className="w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex flex-col gap-1.5">
<div className="flex justify-between items-center w-full">
<span className="text-slate-400">Ceiling Level:</span>
<span className="text-cyan-400 font-bold">{ozState.ceiling.toFixed(2)} dB</span>
</div>
<input
type="range"
min="-12"
max="0"
step="0.1"
value={ozState.ceiling}
onChange={e => setOzState(prev => ({ ...prev, ceiling: parseFloat(e.target.value) }))}
className="w-full h-1 cursor-pointer accent-cyan-400"
/>
</div>
</div>
<div className="col-span-8 grid grid-cols-3 gap-4">
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">UPWARD COMPRESS</span>
<div className="knob-container" data-param="maxUpward" data-min="0" data-max="10" data-value="2.0" data-unit="dB">
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-cyan-500 relative flex items-center justify-center">
<div className="knob-pointer w-0.5 h-4 bg-cyan-400 rounded absolute top-1"></div>
</div>
</div>
<span className="knob-val text-xs font-mono text-slate-200 mt-2">+2.0 dB</span>
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
<MasteringKnob
param="maxUpward"
min={0}
max={10}
value={ozState.maxUpward}
unit="dB"
label="UPWARD COMPRESS"
color="#22d3ee"
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
/>
</div>
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">SOFT CLIPPER</span>
<div className="knob-container" data-param="maxSoftClip" data-min="0" data-max="100" data-value="15" data-unit="%">
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-amber-500 relative flex items-center justify-center">
<div className="knob-pointer w-0.5 h-4 bg-amber-400 rounded absolute top-1"></div>
</div>
</div>
<span className="knob-val text-xs font-mono text-slate-200 mt-2">15%</span>
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
<MasteringKnob
param="maxSoftClip"
min={0}
max={100}
value={ozState.maxSoftClip}
unit="%"
label="SOFT CLIPPER"
color="#fbbf24"
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
/>
</div>
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">TRANSIENT EMPHASIS</span>
<div className="knob-container" data-param="maxTransient" data-min="0" data-max="100" data-value="25" data-unit="%">
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-emerald-500 relative flex items-center justify-center">
<div className="knob-pointer w-0.5 h-4 bg-emerald-400 rounded absolute top-1"></div>
</div>
</div>
<span className="knob-val text-xs font-mono text-slate-200 mt-2">25%</span>
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
<MasteringKnob
param="maxTransient"
min={0}
max={100}
value={ozState.maxTransient}
unit="%"
label="TRANSIENT EMPHASIS"
color="#34d399"
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
/>
</div>
</div>
</div>
</div>
{/* WAVE OBSERVER INTEGRATION */}
<div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0">
{/* Header */}
<div className="flex items-center justify-between pb-1.5 border-b border-slate-800/80 mb-2">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold text-white uppercase tracking-wider">Wave Observer</span>
<span className="text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono">Real-time Oscilloscope</span>
</div>
<div className="flex gap-2">
{['Scope', 'Settings', 'Help', 'About'].map(tab => (
<button key={tab} className={`px-2 py-0.5 rounded text-[10px] font-bold ${tab === 'Scope' ? 'bg-cyan-950 text-cyan-300 border border-cyan-800/60' : 'text-slate-400 hover:text-slate-200'}`}>
{tab}
</button>
))}
</div>
</div>
{/* Scope Canvas */}
<div className="relative w-full h-32 bg-[#090d16] border border-slate-800/80 rounded-lg overflow-hidden mb-2">
<canvas ref={woCanvasRef} className="w-full h-full block"></canvas>
</div>
{/* Controls bar */}
<div className="flex items-center justify-between gap-4 text-[10px] oz-font-mono text-slate-400 mt-1 select-none">
{/* Input level meters */}
<div className="flex items-center gap-2 bg-slate-950 px-2.5 py-1.5 rounded border border-slate-800/80 shrink-0">
<span className="text-[10px] font-bold text-slate-300">Input</span>
<div className="flex flex-col gap-1 w-20">
<div className="flex items-center gap-1">
<span className="text-[8px] text-slate-500 w-2">L</span>
<div className="w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center">
<div ref={woLeftMeterRef} className="h-full bg-cyan-400 transition-all duration-75" style={{ width: '0%' }}></div>
</div>
</div>
<div className="flex items-center gap-1">
<span className="text-[8px] text-slate-500 w-2">R</span>
<div className="w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center">
<div ref={woRightMeterRef} className="h-full bg-teal-500 transition-all duration-75" style={{ width: '0%' }}></div>
</div>
</div>
</div>
</div>
{/* Scope Controls */}
<div className="flex items-center gap-4 bg-slate-950 px-3 py-1 rounded border border-slate-800/80 flex-1 justify-around">
<span className="font-bold text-slate-300 uppercase tracking-widest text-[9px]">Scope</span>
<div className="flex items-center gap-1.5">
<span>Channel</span>
<select value={woChannel} onChange={e => setWoChannel(e.target.value)} className="bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none">
<option value="stereo">Stereo</option>
<option value="left">Left Only</option>
<option value="right">Right Only</option>
</select>
</div>
<div className="flex items-center gap-1.5">
<span>Mode</span>
<select value={woMode} onChange={e => setWoMode(e.target.value)} className="bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none">
<option value="waveform">Waveform</option>
<option value="envelope">Envelope</option>
</select>
</div>
<div className="flex items-center gap-1.5">
<span>Duration:</span>
<span className="text-cyan-400 font-bold w-10 text-right">{woDuration.toFixed(3)}s</span>
<input type="range" min="0.5" max="5.0" step="0.1" value={woDuration} onChange={e => setWoDuration(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer accent-cyan-400" />
</div>
<div className="flex items-center gap-1.5">
<span>V.Zoom:</span>
<span className="text-cyan-400 font-bold w-12 text-right">{woZoom.toFixed(1)} dB</span>
<input type="range" min="-12" max="24" step="0.5" value={woZoom} onChange={e => setWoZoom(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer accent-cyan-400" />
</div>
</div>
<button onClick={() => setWoPaused(!woPaused)} className={`px-3 py-1 rounded font-bold border transition shadow-md shrink-0 flex items-center gap-1 ${woPaused ? 'bg-amber-800 hover:bg-amber-700 border-amber-500 text-amber-100' : 'bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-200'}`}>
{woPaused ? 'Resume' : 'Pause'}
</button>
</div>
</div>
</div>
{/* RIGHT SIDEBAR: I/O METERS */}
@@ -7989,7 +8315,10 @@ const App = () => {
var asCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(asTrk, allTracks) : (asTrk ? asTrk.midiChannel : 0);
var asProg = as.instrumentProgram;
var asSe = as.synth_engine;
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe);
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(as.trackId, scaledVel);
}
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe);
});
}
// Route to ALL armed tracks (not just the first one)
@@ -7998,7 +8327,10 @@ const App = () => {
var atProg = at.instrumentProgram;
var atSe = at.synth_engine;
var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null;
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, atProg, atDest, atCh, atSe);
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(at.id, scaledVel);
}
window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, atProg, atDest, atCh, atSe);
});
}
} else if (cmd === 0x8 || (cmd === 0x9 && rawVel === 0)) {
@@ -8581,6 +8913,15 @@ const App = () => {
sessionTabsRef.current = sessionTabs;
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
const midiVuActivityRef = useRef({});
const triggerMidiVuActivity = (trackId, velocity) => {
if (!trackId) return;
const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
const peak = Math.min(1.0, Math.max(0.15, velFactor));
midiVuActivityRef.current[trackId] = peak;
};
window.triggerMidiVuActivity = triggerMidiVuActivity;
// Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab)
const [tempTabActive, setTempTabActive] = useState(false);
@@ -11715,6 +12056,10 @@ const App = () => {
const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
var trkCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track.midiChannel !== undefined ? track.midiChannel : 0);
// Ensure instrument is loaded in FluidSynth
if (track.synth_engine && track.synth_engine.type === 'soundfont' && track.synth_engine.soundfont_id) {
window.SonicSF.selectInstrument(trkCh, track.synth_engine.soundfont_bank || 0, track.synth_engine.soundfont_program || 0, track.synth_engine.soundfont_id);
}
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
midiItems.forEach(item => {
@@ -11742,6 +12087,12 @@ const App = () => {
trkCh,
track.synth_engine
);
// Trigger VU meter flash when the note starts playing
setTimeout(() => {
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(track.id, note.velocity || 0.8);
}
}, delay * 1000);
} else {
const playOffset = offsetTime - noteStartSec;
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
@@ -11755,6 +12106,10 @@ const App = () => {
trkCh,
track.synth_engine
);
// Trigger VU meter flash instantly
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity(track.id, note.velocity || 0.8);
}
}
}
});
@@ -16525,17 +16880,29 @@ const App = () => {
const canvas = trackVuRefs.current[key];
if (!canvas) return;
let audioPeak = 0;
if (node && node.analyserNode && isPlaying) {
const analyser = node.analyserNode;
const data = new Uint8Array(128);
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;
if (v > audioPeak) audioPeak = v;
}
const db = peak > 0 ? 20 * Math.log10(peak) : -60;
}
let midiPeak = midiVuActivityRef.current[trackId] || 0;
if (midiPeak > 0) {
midiVuActivityRef.current[trackId] = midiPeak * 0.90;
if (midiVuActivityRef.current[trackId] < 0.01) {
midiVuActivityRef.current[trackId] = 0;
}
}
const peak = Math.max(audioPeak, midiPeak);
const db = peak > 0 ? 20 * Math.log10(peak) : -60;
if (peak > 0.001) {
if (key.endsWith('_mixer')) {
drawMixerVuMeter(canvas, peak);
} else {