IMPROVE: bổ sung thêm imager module ho MASTERING PANEL
This commit is contained in:
+124
-32
@@ -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=(100−w)/200 → Mid (L+R)/2 untouched, Side (L−R)/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();
|
||||
|
||||
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);
|
||||
}
|
||||
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 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
@@ -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 {
|
||||
|
||||
@@ -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 = (L−R)/2 × w/100 — chứng minh: g1=(w+100)/200, g2=(100−w)/200 → mid=(g1+g2)=1, side=(g1−g2)=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=(L−R) 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)
|
||||
|
||||
Reference in New Issue
Block a user