IMPROVE: bổ sung thêm imager module ho MASTERING PANEL

This commit is contained in:
2026-08-03 17:54:59 +07:00
parent 8fc1c2641b
commit ed91e4534c
4 changed files with 173 additions and 45 deletions
+123 -31
View File
@@ -114,6 +114,32 @@ function setTrackNodeGain(node, gainLinear) {
node.gainNode.gain.setTargetAtTime(gainLinear, t, 0.02);
}
// Reusable time-domain buffers for the imager vectorscope/correlation meter
// (leftAnalyser/rightAnalyser are fixed at fftSize 2048) allocated once so
// the 60fps render loop does not churn the GC.
const _imagerBufL = new Float32Array(2048);
const _imagerBufR = new Float32Array(2048);
// Real-time stereo correlation (1.0 +1.0) from the master output L/R
// analysers. +0.5+1.0 safe, 0+0.5 caution, <0 phase cancellation (spec §III).
function computeStereoCorrelation() {
if (!masterBus || !masterBus.leftAnalyser || !masterBus.rightAnalyser) return 1.0;
try {
masterBus.leftAnalyser.getFloatTimeDomainData(_imagerBufL);
masterBus.rightAnalyser.getFloatTimeDomainData(_imagerBufR);
let sumLR = 0, sumL2 = 0, sumR2 = 0;
for (let i = 0; i < 2048; i++) {
sumLR += _imagerBufL[i] * _imagerBufR[i];
sumL2 += _imagerBufL[i] * _imagerBufL[i];
sumR2 += _imagerBufR[i] * _imagerBufR[i];
}
if (sumL2 < 1e-9 || sumR2 < 1e-9) return 1.0; // silence neutral
return Math.max(-1, Math.min(1, sumLR / Math.sqrt(sumL2 * sumR2)));
} catch (e) {
return 1.0;
}
}
function makeDistortionCurve(k) {
const n_samples = 44100;
const curve = new Float32Array(n_samples);
@@ -157,11 +183,16 @@ function applyMasteringSettings(s) {
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)
// 2. Imager Settings (Mid/Side width per band imager_spec.md)
// Width % semantics per the guide: 0% = MONO (S × 0), 100% = original
// (S × 1), 200% = double width (S × 2). The L/R crossfeed gains below are
// exactly equivalent to M/S scaling: L'=g1·L+g2·R, R'=g1·R+g2·L with
// g1=(w+100)/200, g2=(100w)/200 Mid (L+R)/2 untouched, Side (LR)/2
// scaled by w/100. Bands: 1=20-100Hz, 2=100Hz-1kHz, 3=1k-6kHz, 4=6k-20kHz.
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;
const widthVal = (s.imagerActive && active) ? clamp(w, 0, 200) : 100;
const g1 = (widthVal + 100) / 200;
const g2 = (100 - widthVal) / 200;
gainLL.gain.setTargetAtTime(g1, now, 0.01);
gainRR.gain.setTargetAtTime(g1, now, 0.01);
@@ -4891,9 +4922,10 @@ const ProfileModal = ({
eqMid2Gain: 2.0,
eqHighGain: 1.8,
w1: 0,
w2: 15,
w3: 35,
w4: 50,
w2: 115,
w3: 135,
w4: 150,
imagerScale: 'v2',
maxGain: 5.4,
maxUpward: 2.0,
maxSoftClip: 15,
@@ -8353,12 +8385,30 @@ const deserializeProjectFromSchema = (schemaObj) => {
};
});
// Migrate mastering settings saved with the OLD imager width scale
// (100..+100, 0 = original width) to the new imager_spec.md scale
// (0..200, 0% = MONO, 100% = original, 200% = double width): old value v
// meant S × (1 + v/100), which equals the new value (v + 100). Projects
// saved after the migration carry `imagerScale: 'v2'` and are kept as-is.
const _migrateMasteringSettings = (ms) => {
if (!ms) return null;
if (ms.imagerScale === 'v2') return ms;
return {
...ms,
w1: (ms.w1 ?? 0) + 100,
w2: (ms.w2 ?? 0) + 100,
w3: (ms.w3 ?? 0) + 100,
w4: (ms.w4 ?? 0) + 100,
imagerScale: 'v2'
};
};
return {
bpm: bpmVal,
tracks: restoredTracks,
sessionTabs: restoredSessionTabs,
subTabs: restoredSubTabs,
masteringSettings: schemaObj.mastering_settings || null
masteringSettings: _migrateMasteringSettings(schemaObj.mastering_settings)
};
};
@@ -8611,29 +8661,67 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
eqCtx.stroke();
}
// Imager Vectorscope
// Imager Vectorscope (real M/S polar trace) + Phase Correlation meter
const imagerCanvas = imagerCanvasRef.current;
if (imagerCanvas) {
const iw = imagerCanvas.width, ih = imagerCanvas.height;
const ic = imagerCanvas.getContext('2d');
ic.clearRect(0, 0, iw, ih);
// Reference circle + crosshair
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();
ic.beginPath();
ic.moveTo(iw / 2 - ih / 3, ih / 2); ic.lineTo(iw / 2 + ih / 3, ih / 2);
ic.moveTo(iw / 2, ih / 2 - ih / 3); ic.lineTo(iw / 2, ih / 2 + ih / 3);
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);
}
const hasL = masterBus && masterBus.leftAnalyser;
const hasR = masterBus && masterBus.rightAnalyser;
if (hasL && hasR) {
try {
masterBus.leftAnalyser.getFloatTimeDomainData(_imagerBufL);
masterBus.rightAnalyser.getFloatTimeDomainData(_imagerBufR);
// Polar (M/S) projection: X = L + R (mid), Y = L R (side).
// A mono signal collapses onto the horizontal axis; widening the
// stereo field grows the vertical spread.
const scale = ih / 3.2;
ic.fillStyle = 'rgba(56, 189, 248, 0.45)';
for (let i = 0; i < 2048; i += 8) {
const x = iw / 2 + (_imagerBufL[i] + _imagerBufR[i]) * scale;
const y = ih / 2 - (_imagerBufL[i] - _imagerBufR[i]) * scale;
if (x >= 0 && x <= iw && y >= 0 && y <= ih) ic.fillRect(x, y, 2, 2);
}
} catch (e) {}
}
// Phase Correlation meter: 1.0 +1.0 with spec color zones
const corr = computeStereoCorrelation();
const gaugeW = iw - 40;
const gaugeY = ih - 12;
// track
ic.fillStyle = 'rgba(30, 41, 59, 0.9)';
ic.fillRect(20, gaugeY, gaugeW, 7);
// zones: danger (<0 red), caution (0..0.5 amber), safe (0.5..1 green)
const zx = (v) => 20 + (v + 1) / 2 * gaugeW;
ic.fillStyle = 'rgba(239, 68, 68, 0.55)'; ic.fillRect(zx(-1), gaugeY, zx(0) - zx(-1), 7);
ic.fillStyle = 'rgba(245, 158, 11, 0.55)'; ic.fillRect(zx(0), gaugeY, zx(0.5) - zx(0), 7);
ic.fillStyle = 'rgba(52, 211, 153, 0.55)'; ic.fillRect(zx(0.5), gaugeY, zx(1) - zx(0.5), 7);
// marker
ic.fillStyle = '#f8fafc';
ic.fillRect(zx(corr) - 1, gaugeY - 2, 2, 11);
// labels
ic.fillStyle = 'rgba(148, 163, 184, 0.8)';
ic.font = '9px monospace';
ic.fillText('1', 20, gaugeY - 4);
ic.fillText('0', iw / 2 - 3, gaugeY - 4);
ic.fillText('+1', iw - 26, gaugeY - 4);
const corrEl = document.getElementById('corrText');
if (corrEl) {
corrEl.innerText = corr.toFixed(2);
corrEl.style.color = corr >= 0.5 ? '#34d399' : (corr >= 0 ? '#fbbf24' : '#ef4444');
}
}
@@ -9012,8 +9100,9 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'imager' ? '' : 'hidden'}`}>
<div className="grid grid-cols-12 gap-4 flex-1">
<div className="col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner">
<span className="text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2">
<i data-lucide="radio" className="w-3.5 h-3.5"></i> Polar Vectorscope & Correlation Meter
<span className="text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2 flex items-center justify-between w-full">
<span><i data-lucide="radio" className="w-3.5 h-3.5"></i> Polar Vectorscope & Correlation Meter</span>
<span className="text-[11px] text-slate-400 normal-case">Corr: <span id="corrText" className="font-bold text-emerald-400">1.00</span></span>
</span>
<div className="flex-1 relative w-full h-56 flex items-center justify-center">
<canvas ref={imagerCanvasRef} className="w-full h-full block"></canvas>
@@ -9021,9 +9110,10 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
</div>
<div className="col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between">
<span className="text-xs font-bold text-slate-300 uppercase oz-font-mono">4-Band Stereo Width</span>
<div className="text-[10px] text-slate-500 oz-font-mono leading-snug">0% = Mono · 100% = Original · 200% = 2× Width</div>
<div className="space-y-3 my-auto">
{[{id:'w1',label:'Band 1 (0-100Hz)',color:'#22d3ee',val:ozState.w1},
{id:'w2',label:'Band 2 (100-1kHz)',color:'#fbbf24',val:ozState.w2},
{[{id:'w1',label:'Band 1 (20-100Hz)',color:'#22d3ee',val:ozState.w1},
{id:'w2',label:'Band 2 (100Hz-1kHz)',color:'#fbbf24',val:ozState.w2},
{id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3},
{id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b => (
<div key={b.id}>
@@ -9031,7 +9121,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
<span style={{color:b.color,fontWeight:700}}>{b.label}</span>
<span id={b.id+'Val'}>{b.val}%</span>
</div>
<input type="range" min="-100" max="100" value={b.val}
<input type="range" min="0" max="200" value={b.val}
onChange={e => setOzState(prev => ({...prev, [b.id]: parseInt(e.target.value)}))}
className="w-full h-1 cursor-pointer" style={{accentColor: b.color}} />
</div>
@@ -12466,9 +12556,10 @@ const App = () => {
eqMid2Gain: 2.0,
eqHighGain: 1.8,
w1: 0,
w2: 15,
w3: 35,
w4: 50,
w2: 115,
w3: 135,
w4: 150,
imagerScale: 'v2',
maxGain: 5.4,
maxUpward: 2.0,
maxSoftClip: 15,
@@ -12700,9 +12791,10 @@ const App = () => {
eqMid2Gain: 2.0,
eqHighGain: 1.8,
w1: 0,
w2: 15,
w3: 35,
w4: 50,
w2: 115,
w3: 135,
w4: 150,
imagerScale: 'v2',
maxGain: 5.4,
maxUpward: 2.0,
maxSoftClip: 15,
File diff suppressed because one or more lines are too long
+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=202608032500" defer></script>
<script src="/static/js/app.precompiled.js?v=202608032600" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+11
View File
@@ -1,3 +1,14 @@
### [2026-08-03] Task: Cài đặt Imager module theo imager_spec.md (M/S width + vectorscope + correlation meter)
- **Tóm tắt thay đổi:** Làm đúng spec imager_spec.md trong MASTERING PANEL (Mixer F7):
1. **DSP width semantics chuẩn spec**: 0% = MONO (S×0), 100% = Original (S×1), 200% = 2× Width (S×2). Giữ cấu trúc 4-band crossover (20-100Hz / 100Hz-1kHz / 1k-6kHz / 6k-20kHz) với matrix L/R crossfeed tương đương M/S (M = (L+R)/2 giữ nguyên, S = (LR)/2 × w/100 — chứng minh: g1=(w+100)/200, g2=(100w)/200 → mid=(g1+g2)=1, side=(g1g2)=w/100).
2. **Migration**: project cũ lưu scale 100..+100 (0 = original) → tự +100 mỗi band khi load (0→100, 15→115, 35→135, 50→150), dùng marker `imagerScale:'v2'` cho project mới. Default mới theo khuyến nghị spec: Band1=0% (MONO maker), Band2=115%, Band3=135%, Band4=150%.
3. **Vectorscope thật (Polar M/S)**: thay chấm ngẫu nhiên giả bằng trace thật X=(L+R), Y=(LR) từ leftAnalyser/rightAnalyser (post-mastering) — mono → nằm ngang, width tăng → trải dọc.
4. **Phase Correlation meter thật**: ρ = Σ(L·R)/√(ΣL²·ΣR²) (1..+1), gauge vẽ zone màu theo spec (+0.5..+1 xanh an toàn, 0..+0.5 vàng cẩn trọng, <0 đỏ nguy hiểm) + hiển thị số "Corr:".
5. UI: slider range 0-200%, nhãn band theo spec, hint "0% = Mono · 100% = Original · 200% = 2× Width".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032600)
- **Ghi chú/Test (nếu có):** verify DSP (w=0→side 0, w=100→side 1, w=200→side 2, mid luôn 1), migration (0,15,35,50 → 100,115,135,150; v2 giữ nguyên), slider trong bundle. `pytest` 86 passed.
---
### [2026-08-03] Task: Mute/Solo realtime cho MIDI items — dùng CC7 (channel volume) của FluidSynth
- **Tóm tắt thay đổi:** Mute/unmute/solo chưa realtime với **MIDI items** vì FluidSynth WASM render toàn bộ channel vào **1 worklet → 1 `_gainNode` chung** (`_workletNode.connect(_gainNode)`), nên gain node của track không câm được MIDI (chỉ audio clips + section sub-tracks qua track gain mới bị ảnh hưởng). Fix: mỗi track MIDI sở hữu **channel riêng** (`ensureTrackMidiChannel`, 0-15 trừ 9) → dùng **CC7 (channel volume)** của FluidSynth (`window.SonicSF.controllerChange(ch, 7, 100|0)`) — áp realtime kể cả với notes đang vang. Thêm vào `applyAllTrackMuteSolo` (nút M/S) + effect sync `[tracks, sessionTabs]` (load/undo). Khi mute → CC7=0 (notes đang phát câm ngay); unmute → CC7=100 (notes đang phát vang lại + cơ chế becameAudible→restart vẫn giữ). Audio clips/section vẫn qua track gain như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032500)