fix: câm toàn cục (IN peak có OUT peak không) - WaveShaper luôn identity curve không null + clamp tần số biquad dưới Nyquist + watchdog tự bypass master chain khi hỏng

This commit is contained in:
2026-08-03 12:30:56 +07:00
parent 94b2d2ef41
commit 7325fbfc45
4 changed files with 59 additions and 22 deletions
+35 -14
View File
@@ -128,12 +128,12 @@ function applyMasteringSettings(s) {
const boostLinear = (s.maximizerActive) ? Math.pow(10, clamp(s.maxGain, -60, 30) / 20) : 1.0;
masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01);
// Soft Clipper
// Soft Clipper (identity passthrough when off never null curve)
if (s.maximizerActive && s.maxSoftClip > 0) {
const k = 1 + (clamp(s.maxSoftClip, 0, 100) / 100) * 10;
masterBus.maximizerSoftClipper.curve = makeDistortionCurve(k);
} else {
masterBus.maximizerSoftClipper.curve = null;
masterBus.maximizerSoftClipper.curve = new Float32Array([-1, 1]);
}
// Upward Compressor
@@ -147,37 +147,45 @@ function applyMasteringSettings(s) {
function initMasterBus(ctx) {
if (masterBus) return masterBus;
// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad
// with frequency Nyquist gets NaN coefficients "BiquadFilterNode: state
// is bad" the whole mastering chain outputs silence (IN peak yes, OUT no).
// Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break
// the 10 kHz highshelf / 6 kHz imager crossover filters.
const maxFilterFreq = (ctx.sampleRate || 44100) * 0.45;
const clampF = v => Math.max(20, Math.min(v, maxFilterFreq));
// Create EQ filters
const eqLowFilter = ctx.createBiquadFilter();
eqLowFilter.type = 'lowshelf';
eqLowFilter.frequency.value = 100;
eqLowFilter.frequency.value = clampF(100);
const eqMid1Filter = ctx.createBiquadFilter();
eqMid1Filter.type = 'peaking';
eqMid1Filter.frequency.value = 822;
eqMid1Filter.frequency.value = clampF(822);
eqMid1Filter.Q.value = 0.7;
const eqMid2Filter = ctx.createBiquadFilter();
eqMid2Filter.type = 'peaking';
eqMid2Filter.frequency.value = 3200;
eqMid2Filter.frequency.value = clampF(3200);
eqMid2Filter.Q.value = 1.2;
const eqHighFilter = ctx.createBiquadFilter();
eqHighFilter.type = 'highshelf';
eqHighFilter.frequency.value = 10000;
eqHighFilter.frequency.value = clampF(10000);
// Create Stereo Imager nodes
const imagerInput = ctx.createGain();
const imagerOutput = ctx.createGain();
// Imager Crossover Filters
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = 100;
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = 100;
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = 1000;
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = 1000;
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = 6000;
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = 6000;
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = clampF(100);
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = clampF(100);
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = clampF(1000);
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = clampF(1000);
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = clampF(6000);
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = clampF(6000);
const split1 = ctx.createChannelSplitter(2);
const split2 = ctx.createChannelSplitter(2);
@@ -235,7 +243,10 @@ function initMasterBus(ctx) {
// Maximizer nodes
const maximizerBoostGain = ctx.createGain();
const maximizerSoftClipper = ctx.createWaveShaper();
maximizerSoftClipper.curve = null;
// NEVER leave the curve null: a WaveShaper with a null/identity curve can
// output silence in some engines, which would kill the whole mastering path.
// Use an explicit linear identity table for passthrough.
maximizerSoftClipper.curve = new Float32Array([-1, 1]);
maximizerSoftClipper.oversample = '4x';
const upwardCompressor = ctx.createDynamicsCompressor();
@@ -8541,6 +8552,16 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText');
renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText');
// Safety watchdog: if the mastering chain is broken (signal in, silence
// out e.g. a biquad in a bad state), fall back to the direct routing so
// audio is NEVER globally silent. The user can re-enable mastering after.
const masterInPk = getPeakLevel(masterBus && masterBus.inputAnalyser);
const masterOutPk = getPeakLevel(masterBus && masterBus.outputAnalyser);
if (masterBus && masterBus.masteringActive && masterInPk > 0.01 && masterOutPk < 0.001) {
console.warn('[Mastering] Chain broken (signal in, no signal out) — bypassing mastering to restore audio.');
toggleMasteringOnMaster(false, false);
}
// Wave Observer Oscilloscope Rendering
const woCanvas = woCanvasRef.current;
if (woCanvas) {
+18 -7
View File
@@ -24,19 +24,27 @@ return Math.min(hi,Math.max(lo,n));};// 1. EQ Settings
// Chromium's "BiquadFilterNode: state is bad").
masterBus.eqLowFilter.gain.cancelScheduledValues(now);masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive?clamp(s.eqLowGain,-24,24):0,now,0.05);masterBus.eqMid1Filter.gain.cancelScheduledValues(now);masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive?clamp(s.eqMid1Gain,-24,24):0,now,0.05);masterBus.eqMid2Filter.gain.cancelScheduledValues(now);masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive?clamp(s.eqMid2Gain,-24,24):0,now,0.05);masterBus.eqHighFilter.gain.cancelScheduledValues(now);masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive?clamp(s.eqHighGain,-24,24):0,now,0.05);// 2. Imager Settings (Mid/Side matrix width control for each band)
const updateImagerBand=(w,active,gainLL,gainRL,gainLR,gainRR)=>{const widthVal=s.imagerActive&&active?clamp(w,-100,100):0;const g1=1+widthVal/200;const g2=-widthVal/200;gainLL.gain.setTargetAtTime(g1,now,0.01);gainRR.gain.setTargetAtTime(g1,now,0.01);gainRL.gain.setTargetAtTime(g2,now,0.01);gainLR.gain.setTargetAtTime(g2,now,0.01);};updateImagerBand(s.w1,true,masterBus.gainLL1,masterBus.gainRL1,masterBus.gainLR1,masterBus.gainRR1);updateImagerBand(s.w2,true,masterBus.gainLL2,masterBus.gainRL2,masterBus.gainLR2,masterBus.gainRR2);updateImagerBand(s.w3,true,masterBus.gainLL3,masterBus.gainRL3,masterBus.gainLR3,masterBus.gainRR3);updateImagerBand(s.w4,true,masterBus.gainLL4,masterBus.gainRL4,masterBus.gainLR4,masterBus.gainRR4);// 3. Maximizer Settings
const boostLinear=s.maximizerActive?Math.pow(10,clamp(s.maxGain,-60,30)/20):1.0;masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear,now,0.01);// Soft Clipper
if(s.maximizerActive&&s.maxSoftClip>0){const k=1+clamp(s.maxSoftClip,0,100)/100*10;masterBus.maximizerSoftClipper.curve=makeDistortionCurve(k);}else{masterBus.maximizerSoftClipper.curve=null;}// Upward Compressor
const boostLinear=s.maximizerActive?Math.pow(10,clamp(s.maxGain,-60,30)/20):1.0;masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear,now,0.01);// Soft Clipper (identity passthrough when off — never null curve)
if(s.maximizerActive&&s.maxSoftClip>0){const k=1+clamp(s.maxSoftClip,0,100)/100*10;masterBus.maximizerSoftClipper.curve=makeDistortionCurve(k);}else{masterBus.maximizerSoftClipper.curve=new Float32Array([-1,1]);}// Upward Compressor
const upwardGainLinear=s.maximizerActive&&s.maxUpward>0?Math.pow(10,clamp(s.maxUpward,0,30)/20)-1.0:0.0;masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear,now,0.01);// Limiter Threshold
const ceilingVal=s.maximizerActive?clamp(s.ceiling,-60,0):-0.1;masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal,now,0.01);}function initMasterBus(ctx){if(masterBus)return masterBus;// Create EQ filters
const eqLowFilter=ctx.createBiquadFilter();eqLowFilter.type='lowshelf';eqLowFilter.frequency.value=100;const eqMid1Filter=ctx.createBiquadFilter();eqMid1Filter.type='peaking';eqMid1Filter.frequency.value=822;eqMid1Filter.Q.value=0.7;const eqMid2Filter=ctx.createBiquadFilter();eqMid2Filter.type='peaking';eqMid2Filter.frequency.value=3200;eqMid2Filter.Q.value=1.2;const eqHighFilter=ctx.createBiquadFilter();eqHighFilter.type='highshelf';eqHighFilter.frequency.value=10000;// Create Stereo Imager nodes
const ceilingVal=s.maximizerActive?clamp(s.ceiling,-60,0):-0.1;masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal,now,0.01);}function initMasterBus(ctx){if(masterBus)return masterBus;// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad
// with frequency ≥ Nyquist gets NaN coefficients → "BiquadFilterNode: state
// is bad" → the whole mastering chain outputs silence (IN peak yes, OUT no).
// Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break
// the 10 kHz highshelf / 6 kHz imager crossover filters.
const maxFilterFreq=(ctx.sampleRate||44100)*0.45;const clampF=v=>Math.max(20,Math.min(v,maxFilterFreq));// Create EQ filters
const eqLowFilter=ctx.createBiquadFilter();eqLowFilter.type='lowshelf';eqLowFilter.frequency.value=clampF(100);const eqMid1Filter=ctx.createBiquadFilter();eqMid1Filter.type='peaking';eqMid1Filter.frequency.value=clampF(822);eqMid1Filter.Q.value=0.7;const eqMid2Filter=ctx.createBiquadFilter();eqMid2Filter.type='peaking';eqMid2Filter.frequency.value=clampF(3200);eqMid2Filter.Q.value=1.2;const eqHighFilter=ctx.createBiquadFilter();eqHighFilter.type='highshelf';eqHighFilter.frequency.value=clampF(10000);// Create Stereo Imager nodes
const imagerInput=ctx.createGain();const imagerOutput=ctx.createGain();// Imager Crossover Filters
const f1_lp=ctx.createBiquadFilter();f1_lp.type='lowpass';f1_lp.frequency.value=100;const f2_hp=ctx.createBiquadFilter();f2_hp.type='highpass';f2_hp.frequency.value=100;const f2_lp=ctx.createBiquadFilter();f2_lp.type='lowpass';f2_lp.frequency.value=1000;const f3_hp=ctx.createBiquadFilter();f3_hp.type='highpass';f3_hp.frequency.value=1000;const f3_lp=ctx.createBiquadFilter();f3_lp.type='lowpass';f3_lp.frequency.value=6000;const f4_hp=ctx.createBiquadFilter();f4_hp.type='highpass';f4_hp.frequency.value=6000;const split1=ctx.createChannelSplitter(2);const split2=ctx.createChannelSplitter(2);const split3=ctx.createChannelSplitter(2);const split4=ctx.createChannelSplitter(2);const merge1=ctx.createChannelMerger(2);const merge2=ctx.createChannelMerger(2);const merge3=ctx.createChannelMerger(2);const merge4=ctx.createChannelMerger(2);const gainLL1=ctx.createGain();const gainRL1=ctx.createGain();const gainLR1=ctx.createGain();const gainRR1=ctx.createGain();const gainLL2=ctx.createGain();const gainRL2=ctx.createGain();const gainLR2=ctx.createGain();const gainRR2=ctx.createGain();const gainLL3=ctx.createGain();const gainRL3=ctx.createGain();const gainLR3=ctx.createGain();const gainRR3=ctx.createGain();const gainLL4=ctx.createGain();const gainRL4=ctx.createGain();const gainLR4=ctx.createGain();const gainRR4=ctx.createGain();// Connections for Imager DSP
const f1_lp=ctx.createBiquadFilter();f1_lp.type='lowpass';f1_lp.frequency.value=clampF(100);const f2_hp=ctx.createBiquadFilter();f2_hp.type='highpass';f2_hp.frequency.value=clampF(100);const f2_lp=ctx.createBiquadFilter();f2_lp.type='lowpass';f2_lp.frequency.value=clampF(1000);const f3_hp=ctx.createBiquadFilter();f3_hp.type='highpass';f3_hp.frequency.value=clampF(1000);const f3_lp=ctx.createBiquadFilter();f3_lp.type='lowpass';f3_lp.frequency.value=clampF(6000);const f4_hp=ctx.createBiquadFilter();f4_hp.type='highpass';f4_hp.frequency.value=clampF(6000);const split1=ctx.createChannelSplitter(2);const split2=ctx.createChannelSplitter(2);const split3=ctx.createChannelSplitter(2);const split4=ctx.createChannelSplitter(2);const merge1=ctx.createChannelMerger(2);const merge2=ctx.createChannelMerger(2);const merge3=ctx.createChannelMerger(2);const merge4=ctx.createChannelMerger(2);const gainLL1=ctx.createGain();const gainRL1=ctx.createGain();const gainLR1=ctx.createGain();const gainRR1=ctx.createGain();const gainLL2=ctx.createGain();const gainRL2=ctx.createGain();const gainLR2=ctx.createGain();const gainRR2=ctx.createGain();const gainLL3=ctx.createGain();const gainRL3=ctx.createGain();const gainLR3=ctx.createGain();const gainRR3=ctx.createGain();const gainLL4=ctx.createGain();const gainRL4=ctx.createGain();const gainLR4=ctx.createGain();const gainRR4=ctx.createGain();// Connections for Imager DSP
imagerInput.connect(f1_lp);imagerInput.connect(f2_hp);f2_hp.connect(f2_lp);imagerInput.connect(f3_hp);f3_hp.connect(f3_lp);imagerInput.connect(f4_hp);// Band 1
f1_lp.connect(split1);split1.connect(gainLL1,0);split1.connect(gainLR1,0);split1.connect(gainRL1,1);split1.connect(gainRR1,1);gainLL1.connect(merge1,0,0);gainRL1.connect(merge1,0,0);gainLR1.connect(merge1,0,1);gainRR1.connect(merge1,0,1);merge1.connect(imagerOutput);// Band 2
f2_lp.connect(split2);split2.connect(gainLL2,0);split2.connect(gainLR2,0);split2.connect(gainRL2,1);split2.connect(gainRR2,1);gainLL2.connect(merge2,0,0);gainRL2.connect(merge2,0,0);gainLR2.connect(merge2,0,1);gainRR2.connect(merge2,0,1);merge2.connect(imagerOutput);// Band 3
f3_lp.connect(split3);split3.connect(gainLL3,0);split3.connect(gainLR3,0);split3.connect(gainRL3,1);split3.connect(gainRR3,1);gainLL3.connect(merge3,0,0);gainRL3.connect(merge3,0,0);gainLR3.connect(merge3,0,1);gainRR3.connect(merge3,0,1);merge3.connect(imagerOutput);// Band 4
f4_hp.connect(split4);split4.connect(gainLL4,0);split4.connect(gainLR4,0);split4.connect(gainRL4,1);split4.connect(gainRR4,1);gainLL4.connect(merge4,0,0);gainRL4.connect(merge4,0,0);gainLR4.connect(merge4,0,1);gainRR4.connect(merge4,0,1);merge4.connect(imagerOutput);// Maximizer nodes
const maximizerBoostGain=ctx.createGain();const maximizerSoftClipper=ctx.createWaveShaper();maximizerSoftClipper.curve=null;maximizerSoftClipper.oversample='4x';const upwardCompressor=ctx.createDynamicsCompressor();upwardCompressor.threshold.value=-30;upwardCompressor.knee.value=10;upwardCompressor.ratio.value=4;upwardCompressor.attack.value=0.01;upwardCompressor.release.value=0.1;const upwardGain=ctx.createGain();upwardGain.gain.value=0.0;const upwardSummingGain=ctx.createGain();maximizerBoostGain.connect(maximizerSoftClipper);maximizerSoftClipper.connect(upwardSummingGain);maximizerBoostGain.connect(upwardCompressor);upwardCompressor.connect(upwardGain);upwardGain.connect(upwardSummingGain);const maximizerCompressor=ctx.createDynamicsCompressor();maximizerCompressor.threshold.value=-0.1;maximizerCompressor.knee.value=0.0;maximizerCompressor.ratio.value=20.0;maximizerCompressor.attack.value=0.001;maximizerCompressor.release.value=0.05;upwardSummingGain.connect(maximizerCompressor);// Setup Analysers
const maximizerBoostGain=ctx.createGain();const maximizerSoftClipper=ctx.createWaveShaper();// NEVER leave the curve null: a WaveShaper with a null/identity curve can
// output silence in some engines, which would kill the whole mastering path.
// Use an explicit linear identity table for passthrough.
maximizerSoftClipper.curve=new Float32Array([-1,1]);maximizerSoftClipper.oversample='4x';const upwardCompressor=ctx.createDynamicsCompressor();upwardCompressor.threshold.value=-30;upwardCompressor.knee.value=10;upwardCompressor.ratio.value=4;upwardCompressor.attack.value=0.01;upwardCompressor.release.value=0.1;const upwardGain=ctx.createGain();upwardGain.gain.value=0.0;const upwardSummingGain=ctx.createGain();maximizerBoostGain.connect(maximizerSoftClipper);maximizerSoftClipper.connect(upwardSummingGain);maximizerBoostGain.connect(upwardCompressor);upwardCompressor.connect(upwardGain);upwardGain.connect(upwardSummingGain);const maximizerCompressor=ctx.createDynamicsCompressor();maximizerCompressor.threshold.value=-0.1;maximizerCompressor.knee.value=0.0;maximizerCompressor.ratio.value=20.0;maximizerCompressor.attack.value=0.001;maximizerCompressor.release.value=0.05;upwardSummingGain.connect(maximizerCompressor);// Setup Analysers
const inputAnalyser=ctx.createAnalyser();inputAnalyser.fftSize=2048;const outputAnalyser=ctx.createAnalyser();outputAnalyser.fftSize=2048;// Global fader / output
const output=ctx.createGain();output.gain.value=1.0;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(),analyser,output,masteringActive:false,// Analysers for metering
inputAnalyser,outputAnalyser,leftAnalyser,rightAnalyser,// Mastering nodes
@@ -253,7 +261,10 @@ const MasteringModal=({isOpen,onClose,masteringSettings,setMasteringSettings})=>
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;function startAudioDemo(){getAudioContext();const ctx=audioCtx;if(ctx.state==='suspended')ctx.resume();stopAudioDemo();const sampleRate=ctx.sampleRate;const bufferSize=sampleRate*4;const buffer=ctx.createBuffer(2,bufferSize,sampleRate);const left=buffer.getChannelData(0);const right=buffer.getChannelData(1);for(let i=0;i<bufferSize;i++){const t=i/sampleRate;const kickEnv=Math.max(0,1-t%0.5*8);const kick=Math.sin(2*Math.PI*(55*Math.exp(-(t%0.5)*18)))*kickEnv;const snareEnv=Math.max(0,1-(t+0.25)%0.5*6);const snare=(Math.random()*2-1)*snareEnv*0.3;const synth=(Math.sin(2*Math.PI*261.63*t)+Math.sin(2*Math.PI*311.13*t)+Math.sin(2*Math.PI*392.0*t))*0.12;left[i]=kick*0.6+snare+synth;right[i]=kick*0.6+snare*0.9+synth*0.95;}const source=ctx.createBufferSource();source.buffer=buffer;source.loop=true;source.connect(masterBus.inputAnalyser);source.start();audioRef.current.source=source;setIsPlaying(true);}function stopAudioDemo(){const src=audioRef.current.source;if(src){try{src.stop();}catch(e){}try{src.disconnect();}catch(e){}audioRef.current.source=null;}setIsPlaying(false);}React.useEffect(()=>{if(!isOpen)return;getAudioContext();function resizeAll(){const resizeCanvas=ref=>{const el=ref.current;if(el){el.width=el.clientWidth;el.height=el.clientHeight;}};resizeCanvas(eqCanvasRef);resizeCanvas(imagerCanvasRef);resizeCanvas(inMeterCanvasRef);resizeCanvas(outMeterCanvasRef);resizeCanvas(woCanvasRef);}resizeAll();window.addEventListener('resize',resizeAll);const fftData=new Uint8Array(1024);function getPeakLevel(analyser){if(!analyser)return 0;const bufferLength=analyser.fftSize;const dataArray=new Float32Array(bufferLength);analyser.getFloatTimeDomainData(dataArray);let maxVal=0;for(let i=0;i<bufferLength;i++){const val=Math.abs(dataArray[i]);if(val>maxVal){maxVal=val;}}return maxVal;}function renderFrame(){animFrameRef.current=requestAnimationFrame(renderFrame);const s=ozStateRef.current;// EQ Spectrum
const eqCanvas=eqCanvasRef.current;if(eqCanvas){const w=eqCanvas.width,h=eqCanvas.height;const eqCtx=eqCanvas.getContext('2d');eqCtx.clearRect(0,0,w,h);eqCtx.strokeStyle='rgba(51, 65, 85, 0.3)';eqCtx.lineWidth=1;eqCtx.font='9px JetBrains Mono';eqCtx.fillStyle='#475569';const freqs=[20,50,100,200,500,1000,2000,5000,10000,20000];freqs.forEach(f=>{const x=Math.log10(f/20)/Math.log10(20000/20)*w;eqCtx.beginPath();eqCtx.moveTo(x,0);eqCtx.lineTo(x,h);eqCtx.stroke();if(f>=1000)eqCtx.fillText(`${f/1000}k`,x+3,h-6);else eqCtx.fillText(`${f}`,x+3,h-6);});if(masterBus&&masterBus.outputAnalyser){masterBus.outputAnalyser.getByteFrequencyData(fftData);eqCtx.fillStyle='rgba(56, 189, 248, 0.15)';const barWidth=w/128;for(let i=0;i<128;i++){const val=fftData[i*4]/255;eqCtx.fillRect(i*barWidth,h-val*h,barWidth-1,val*h);}}eqCtx.strokeStyle='#38bdf8';eqCtx.lineWidth=2.5;eqCtx.beginPath();for(let x=0;x<w;x++){const freq=20*Math.pow(20000/20,x/w);let gainDb=0;if(s.eqActive){gainDb+=s.eqLowGain/(1+Math.pow(freq/100,2));gainDb+=s.eqMid1Gain*Math.exp(-Math.pow(Math.log(freq/822),2)*2);gainDb+=s.eqMid2Gain*Math.exp(-Math.pow(Math.log(freq/3200),2)*2);gainDb+=s.eqHighGain/(1+Math.pow(10000/freq,2));}const y=h/2-gainDb/18*(h/2);if(x===0)eqCtx.moveTo(x,y);else eqCtx.lineTo(x,y);}eqCtx.stroke();}// Imager Vectorscope
const imagerCanvas=imagerCanvasRef.current;if(imagerCanvas){const iw=imagerCanvas.width,ih=imagerCanvas.height;const ic=imagerCanvas.getContext('2d');ic.clearRect(0,0,iw,ih);ic.strokeStyle='rgba(51, 65, 85, 0.4)';ic.lineWidth=1;ic.beginPath();ic.arc(iw/2,ih/2,ih/3,0,Math.PI*2);ic.stroke();const outPeak=getPeakLevel(masterBus&&masterBus.outputAnalyser);if(outPeak>0.001){ic.fillStyle='#38bdf8';const maxRadius=ih/3.2*Math.min(1.0,outPeak*1.5);for(let i=0;i<40;i++){const angle=(Math.random()-0.5)*(Math.PI/2)+-Math.PI/2;const radius=Math.random()*maxRadius;const x=iw/2+Math.cos(angle)*radius*(1+s.w3/100);const y=ih/2+Math.sin(angle)*radius;ic.fillRect(x,y,2,2);}}}// I/O Meters
const renderMeter=(analyser,ctxRef,textId)=>{const canvas=ctxRef.current;if(!canvas)return;const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');ctx.clearRect(0,0,w,h);const peak=getPeakLevel(analyser);const barH=Math.min(1.0,peak)*h;const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#38bdf8');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(1,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(2,h-barH,w-4,barH);const el=document.getElementById(textId);if(el){if(peak>0){const dbVal=20*Math.log10(peak);el.innerText=dbVal<-90?'-inf dB':`${dbVal.toFixed(1)} dB`;}else{el.innerText='-inf dB';}}};renderMeter(masterBus&&masterBus.inputAnalyser,inMeterCanvasRef,'inPeakText');renderMeter(masterBus&&masterBus.outputAnalyser,outMeterCanvasRef,'outPeakText');// Wave Observer Oscilloscope Rendering
const renderMeter=(analyser,ctxRef,textId)=>{const canvas=ctxRef.current;if(!canvas)return;const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');ctx.clearRect(0,0,w,h);const peak=getPeakLevel(analyser);const barH=Math.min(1.0,peak)*h;const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#38bdf8');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(1,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(2,h-barH,w-4,barH);const el=document.getElementById(textId);if(el){if(peak>0){const dbVal=20*Math.log10(peak);el.innerText=dbVal<-90?'-inf dB':`${dbVal.toFixed(1)} dB`;}else{el.innerText='-inf dB';}}};renderMeter(masterBus&&masterBus.inputAnalyser,inMeterCanvasRef,'inPeakText');renderMeter(masterBus&&masterBus.outputAnalyser,outMeterCanvasRef,'outPeakText');// Safety watchdog: if the mastering chain is broken (signal in, silence
// out — e.g. a biquad in a bad state), fall back to the direct routing so
// audio is NEVER globally silent. The user can re-enable mastering after.
const masterInPk=getPeakLevel(masterBus&&masterBus.inputAnalyser);const masterOutPk=getPeakLevel(masterBus&&masterBus.outputAnalyser);if(masterBus&&masterBus.masteringActive&&masterInPk>0.01&&masterOutPk<0.001){console.warn('[Mastering] Chain broken (signal in, no signal out) — bypassing mastering to restore audio.');toggleMasteringOnMaster(false,false);}// 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
+1 -1
View File
@@ -24,7 +24,7 @@
<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/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608031400" defer></script>
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+5
View File
@@ -1269,3 +1269,8 @@
- **Tóm tắt thay đổi:** User xác nhận: sau nhấp đôi MIDI item vào Piano Roll, câm TOÀN CỤC (main cũng hết tiếng) + lỗi `BiquadFilterNode: state is bad` vẫn còn. Nguyên nhân: master chain (EQ/imager biquad) bị `toggleMasteringOnMaster` + `applyMasteringSettings` gọi lại **mỗi lần** `getAudioContext()` (play, stopAll, double-click, VU...) — burst "fast parameter automation" vào biquad làm Chromium báo state bad, và nếu routing master bị ngắt giữa chừng → câm toàn cục. Fix triệt để: (1) **gỡ mastering khỏi getAudioContext** — master chain chỉ được áp dụng 1 lần khi `initMasterBus` tạo (nếu có `currentMasteringSettings`) + qua React effect khi settings đổi; (2) **time constant 0.01 → 0.05** + `cancelScheduledValues` trước mỗi `setTargetAtTime` EQ — hết tích tụ automation event khi kéo slider; (3) `toggleMasteringOnMaster` bọc try/catch, **luôn reconnect lại routing hợp lệ** dù lỗi — không bao giờ để inputAnalyser bị ngắt không nối (gây câm toàn cục).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `toggleMasteringOnMaster` còn 3 chỗ (def + initMasterBus + effect). 9 harness (single/multi/dedup/reset/channels/render/pr_timing/retry/dbl) vẫn PASS. Hard refresh (Ctrl+F5). Nếu lỗi biquad vẫn hiện khi bật MasteringModal và kéo EQ, đó có thể là false-positive Chromium (parallel biquad) — báo tôi để tôi đổi cấu trúc imager/EQ.
### [2026-08-03 14:15] Task: Fix câm toàn cục - IN peak có, OUT peak không (master chain chết)
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.