Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca | |||
| 289746f187 | |||
| b3ced7a7b3 | |||
| 4233c1eeda | |||
| 35f7c3822f | |||
| d37f4e7557 | |||
| 8a9d6b3c27 | |||
| 55d3464b1e |
@@ -151,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
|||||||
@router.get("/soundfonts/download/{sf_id}")
|
@router.get("/soundfonts/download/{sf_id}")
|
||||||
async def download_soundfont_asset(sf_id: str):
|
async def download_soundfont_asset(sf_id: str):
|
||||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||||
|
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||||
|
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||||
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||||
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||||
if not os.path.isdir(base_dir):
|
if not os.path.isdir(base_dir):
|
||||||
continue
|
continue
|
||||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||||
|
|||||||
+6
-1
@@ -81,7 +81,12 @@ async def get_index():
|
|||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||||
|
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||||
|
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
|
|||||||
+370
-63
@@ -112,6 +112,20 @@ const trackMasteringBypassMap = {};
|
|||||||
const trackAudioBypassMap = {};
|
const trackAudioBypassMap = {};
|
||||||
const trackMidiBypassMap = {};
|
const trackMidiBypassMap = {};
|
||||||
|
|
||||||
|
// Mastering chain ON? (masterConnected && !isBypassed)
|
||||||
|
const masteringChainOn = () => !!(window.currentMasteringSettings && window.currentMasteringSettings.masterConnected && !window.currentMasteringSettings.isBypassed);
|
||||||
|
// ♪ bypass hiệu lực CHỈ khi mastering chain TẮT — khi chain ON, MỌI track
|
||||||
|
// (solo/preview/play) PHẢI đi qua mastering chain (user requirement: âm phải
|
||||||
|
// qua chain để đủ lớn). Chain OFF → theo ♪ maps như cũ.
|
||||||
|
const effMidiBypass = (track) => {
|
||||||
|
if (masteringChainOn()) return false;
|
||||||
|
return trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass);
|
||||||
|
};
|
||||||
|
const effAudioBypass = (track) => {
|
||||||
|
if (masteringChainOn()) return false;
|
||||||
|
return trackAudioBypassMap[track.id] !== undefined ? !!trackAudioBypassMap[track.id] : !!(track.audioBypass ?? track.masteringBypass);
|
||||||
|
};
|
||||||
|
|
||||||
// Build the dual routing for one track: routeGain -> mastering chain (normal),
|
// Build the dual routing for one track: routeGain -> mastering chain (normal),
|
||||||
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
|
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
|
||||||
function createMasteringRoute(ctx, track, bus) {
|
function createMasteringRoute(ctx, track, bus) {
|
||||||
@@ -121,9 +135,11 @@ function createMasteringRoute(ctx, track, bus) {
|
|||||||
let bypass = false;
|
let bypass = false;
|
||||||
if (track && track.id && trackAudioBypassMap[track.id] !== undefined) {
|
if (track && track.id && trackAudioBypassMap[track.id] !== undefined) {
|
||||||
bypass = !!trackAudioBypassMap[track.id];
|
bypass = !!trackAudioBypassMap[track.id];
|
||||||
} else {
|
} else if (track) {
|
||||||
bypass = !!(track && (track.audioBypass ?? track.masteringBypass));
|
bypass = !!(track.audioBypass ?? track.masteringBypass);
|
||||||
}
|
}
|
||||||
|
// Mastering chain ON → MỌI track qua chain (♪ bị override — user requirement)
|
||||||
|
if (masteringChainOn()) bypass = false;
|
||||||
const routeGain = ctx.createGain();
|
const routeGain = ctx.createGain();
|
||||||
const dryGain = ctx.createGain();
|
const dryGain = ctx.createGain();
|
||||||
const masterDest = bus ? bus.input : ctx.destination;
|
const masterDest = bus ? bus.input : ctx.destination;
|
||||||
@@ -336,9 +352,9 @@ function applyMasteringSettings(s) {
|
|||||||
const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, clamp(s.maxUpward, 0, 30) / 20) - 1.0) : 0.0;
|
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);
|
masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear, now, 0.01);
|
||||||
|
|
||||||
// Limiter Threshold
|
// Limiter Threshold (WaveShaper ceiling — _setCeiling rebuild curve)
|
||||||
const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1;
|
const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1;
|
||||||
masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01);
|
if (masterBus.maximizerCompressor._setCeiling) masterBus.maximizerCompressor._setCeiling(ceilingVal);
|
||||||
|
|
||||||
// 4. Bus Compressor module (mastering_expand.md §II.2)
|
// 4. Bus Compressor module (mastering_expand.md §II.2)
|
||||||
if (masterBus.compNode) {
|
if (masterBus.compNode) {
|
||||||
@@ -348,11 +364,14 @@ function applyMasteringSettings(s) {
|
|||||||
masterBus.compMakeup.gain.setTargetAtTime(compOn ? Math.pow(10, clamp(s.compMakeup, 0, 12) / 20) : 1.0, now, 0.02);
|
masterBus.compMakeup.gain.setTargetAtTime(compOn ? Math.pow(10, clamp(s.compMakeup, 0, 12) / 20) : 1.0, now, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Brickwall Limiter module (ratio 20:1, knee 0)
|
// 5. Brickwall Limiter module (WaveShaper tanh — threshold = mức clip; OFF = identity)
|
||||||
if (masterBus.limNode) {
|
if (masterBus.limNode) {
|
||||||
const limOn = !!s.limActive;
|
const limOn = !!s.limActive;
|
||||||
masterBus.limNode.threshold.setTargetAtTime(limOn ? clamp(s.limThreshold, -24, 0) : 0, now, 0.02);
|
if (limOn) {
|
||||||
masterBus.limNode.ratio.setTargetAtTime(limOn ? 20 : 1, now, 0.02);
|
if (masterBus.limNode._setThreshold) masterBus.limNode._setThreshold(clamp(s.limThreshold, -24, 0));
|
||||||
|
} else {
|
||||||
|
try { masterBus.limNode.curve = new Float32Array([-1, 1]); } catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Harmonic Exciter module (dry/wet mix; dry stays 1.0 for subtle warmth)
|
// 6. Harmonic Exciter module (dry/wet mix; dry stays 1.0 for subtle warmth)
|
||||||
@@ -501,12 +520,21 @@ function initMasterBus(ctx) {
|
|||||||
upwardCompressor.connect(upwardGain);
|
upwardCompressor.connect(upwardGain);
|
||||||
upwardGain.connect(upwardSummingGain);
|
upwardGain.connect(upwardSummingGain);
|
||||||
|
|
||||||
const maximizerCompressor = ctx.createDynamicsCompressor();
|
// Brickwall Limiter tại ceiling: WaveShaper HARD CLIP (slope 1 — không boost,
|
||||||
maximizerCompressor.threshold.value = -0.1;
|
// clip chính xác tại ceiling) — KHÔNG DynamicsCompressor (NaN trên bass
|
||||||
maximizerCompressor.knee.value = 0.0;
|
// transient → chain state-bad → CÂM + stuck).
|
||||||
maximizerCompressor.ratio.value = 20.0;
|
const maximizerCompressor = ctx.createWaveShaper();
|
||||||
maximizerCompressor.attack.value = 0.001;
|
maximizerCompressor.oversample = '2x';
|
||||||
maximizerCompressor.release.value = 0.05;
|
let _maxCeil = -0.1;
|
||||||
|
const _buildMaxCurve = (db) => {
|
||||||
|
const c = Math.pow(10, Math.max(-60, Math.min(0, db)) / 20);
|
||||||
|
const _c = new Float32Array(4096);
|
||||||
|
for (let _i = 0; _i < 4096; _i++) { const _x = (_i / 4095) * 2 - 1; _c[_i] = Math.max(-c, Math.min(c, _x)); }
|
||||||
|
maximizerCompressor.curve = _c;
|
||||||
|
_maxCeil = db;
|
||||||
|
};
|
||||||
|
_buildMaxCurve(-0.1);
|
||||||
|
maximizerCompressor._setCeiling = (db) => { if (db !== _maxCeil) _buildMaxCurve(db); };
|
||||||
|
|
||||||
upwardSummingGain.connect(maximizerCompressor);
|
upwardSummingGain.connect(maximizerCompressor);
|
||||||
|
|
||||||
@@ -525,14 +553,23 @@ function initMasterBus(ctx) {
|
|||||||
compNode.connect(compMakeup);
|
compNode.connect(compMakeup);
|
||||||
compMakeup.connect(compOutput);
|
compMakeup.connect(compOutput);
|
||||||
|
|
||||||
// ── Brickwall Limiter module (ratio 20:1, knee 0) ──
|
// ── Brickwall Limiter module (WaveShaper tanh soft-clip — KHÔNG
|
||||||
|
// DynamicsCompressor: NaN trên bass transient → chain stuck) ──
|
||||||
const limInput = ctx.createGain();
|
const limInput = ctx.createGain();
|
||||||
const limNode = ctx.createDynamicsCompressor();
|
const limNode = ctx.createWaveShaper();
|
||||||
limNode.threshold.value = -1.0;
|
limNode.oversample = '2x';
|
||||||
limNode.knee.value = 0;
|
let _limLastThresh = null;
|
||||||
limNode.ratio.value = 20;
|
const _buildLimCurve = (db) => {
|
||||||
limNode.attack.value = 0.001;
|
const tLin = Math.pow(10, Math.max(-24, Math.min(0, db)) / 20);
|
||||||
limNode.release.value = 0.05;
|
const k = 1 / Math.max(0.02, tLin);
|
||||||
|
const _c = new Float32Array(4096);
|
||||||
|
const _tk = Math.tanh(k);
|
||||||
|
for (let _i = 0; _i < 4096; _i++) { const _x = (_i / 4095) * 2 - 1; _c[_i] = Math.tanh(_x * k) / _tk; }
|
||||||
|
limNode.curve = _c;
|
||||||
|
_limLastThresh = db;
|
||||||
|
};
|
||||||
|
_buildLimCurve(-1.0);
|
||||||
|
limNode._setThreshold = (db) => { if (db !== _limLastThresh) _buildLimCurve(db); };
|
||||||
const limOutput = ctx.createGain();
|
const limOutput = ctx.createGain();
|
||||||
limInput.connect(limNode);
|
limInput.connect(limNode);
|
||||||
limNode.connect(limOutput);
|
limNode.connect(limOutput);
|
||||||
@@ -643,10 +680,12 @@ function initMasterBus(ctx) {
|
|||||||
eqMid1Filter.connect(eqMid2Filter);
|
eqMid1Filter.connect(eqMid2Filter);
|
||||||
eqMid2Filter.connect(eqHighFilter);
|
eqMid2Filter.connect(eqHighFilter);
|
||||||
|
|
||||||
// Setup default non-mastered routing:
|
// Setup default non-mastered routing (KHÔNG compressor mặc định trong path):
|
||||||
// input -> compressor -> inputAnalyser -> outputAnalyser -> output -> analyser -> destination
|
// input -> inputAnalyser -> outputAnalyser -> output -> analyser -> destination
|
||||||
masterBus.input.connect(masterBus.compressor);
|
// Compressor mặc định (ratio 12, threshold -24 — LUÔN-ON) vừa (a) pump-down
|
||||||
masterBus.compressor.connect(masterBus.inputAnalyser);
|
// tín hiệu → âm nhỏ/méo, vừa (b) phát NaN khi gặp bass transient → 11 biquad
|
||||||
|
// "state is bad" → CÂM + stuck. Mastering chain có comp/lim module riêng khi bật.
|
||||||
|
masterBus.input.connect(masterBus.inputAnalyser);
|
||||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||||
masterBus.outputAnalyser.connect(masterBus.output);
|
masterBus.outputAnalyser.connect(masterBus.output);
|
||||||
masterBus.output.connect(masterBus.analyser);
|
masterBus.output.connect(masterBus.analyser);
|
||||||
@@ -657,6 +696,11 @@ function initMasterBus(ctx) {
|
|||||||
// masteringSettings effect for subsequent changes — see useEffect).
|
// masteringSettings effect for subsequent changes — see useEffect).
|
||||||
if (window.currentMasteringSettings) {
|
if (window.currentMasteringSettings) {
|
||||||
try {
|
try {
|
||||||
|
// Reset sig-cache: chain MỚI (biquad/maximizer node mới) giữ giá trị
|
||||||
|
// INIT (gain 0, width 100…) — applyMasteringSettings early-return vì
|
||||||
|
// _lastMasteringSig không đổi (module-level, persist qua recreation) →
|
||||||
|
// chain FLAT ("spectrum hiển thị nhưng không xử lí âm thanh").
|
||||||
|
_lastMasteringSig = null;
|
||||||
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
|
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
|
||||||
applyMasteringSettings(window.currentMasteringSettings);
|
applyMasteringSettings(window.currentMasteringSettings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -867,12 +911,25 @@ function createTrackFxModule(type, ctx, params) {
|
|||||||
input.connect(comp); comp.connect(makeup); makeup.connect(output);
|
input.connect(comp); comp.connect(makeup); makeup.connect(output);
|
||||||
nodes = { comp, makeup };
|
nodes = { comp, makeup };
|
||||||
} else if (type === 'limiter') {
|
} else if (type === 'limiter') {
|
||||||
const lim = ctx.createDynamicsCompressor();
|
// Brickwall Limiter bằng WaveShaper tanh soft-clip — KHÔNG DynamicsCompressor:
|
||||||
lim.threshold.value = num(p.ceiling, -1.0);
|
// Chromium compressor phát NaN với bass transient mạnh (pitch thấp + vel cao
|
||||||
lim.knee.value = 0; lim.ratio.value = 20;
|
// đồng loạt) → NaN vào master chain → 11 biquad "state is bad" → CÂM + stuck.
|
||||||
lim.attack.value = 0.001; lim.release.value = 0.05;
|
const shaper = ctx.createWaveShaper();
|
||||||
input.connect(lim); lim.connect(output);
|
shaper.oversample = '2x';
|
||||||
nodes = { lim };
|
const ceilingDb = Math.min(0, num(p.ceiling, -1.0));
|
||||||
|
const threshLin = Math.pow(10, ceilingDb / 20);
|
||||||
|
const k = 1 / Math.max(0.02, threshLin);
|
||||||
|
const _curve = new Float32Array(4096);
|
||||||
|
const _tanhK = Math.tanh(k);
|
||||||
|
for (let _i = 0; _i < 4096; _i++) {
|
||||||
|
const _x = (_i / 4095) * 2 - 1;
|
||||||
|
_curve[_i] = Math.tanh(_x * k) / _tanhK;
|
||||||
|
}
|
||||||
|
shaper.curve = _curve;
|
||||||
|
const makeup = ctx.createGain();
|
||||||
|
makeup.gain.value = 1.0;
|
||||||
|
input.connect(shaper); shaper.connect(makeup); makeup.connect(output);
|
||||||
|
nodes = { shaper, makeup };
|
||||||
} else if (type === 'exciter') {
|
} else if (type === 'exciter') {
|
||||||
const hp = ctx.createBiquadFilter();
|
const hp = ctx.createBiquadFilter();
|
||||||
hp.type = 'highpass'; hp.frequency.value = clampF(2000); hp.Q.value = 0.7;
|
hp.type = 'highpass'; hp.frequency.value = clampF(2000); hp.Q.value = 0.7;
|
||||||
@@ -971,7 +1028,7 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
|
|||||||
sfOut.connect(sfPan);
|
sfOut.connect(sfPan);
|
||||||
const sfRouteGain = ctx.createGain();
|
const sfRouteGain = ctx.createGain();
|
||||||
const sfDryGain = ctx.createGain();
|
const sfDryGain = ctx.createGain();
|
||||||
const sfBypass = trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass);
|
const sfBypass = effMidiBypass(track);
|
||||||
sfRouteGain.gain.value = sfBypass ? 0 : 1;
|
sfRouteGain.gain.value = sfBypass ? 0 : 1;
|
||||||
sfDryGain.gain.value = sfBypass ? 1 : 0;
|
sfDryGain.gain.value = sfBypass ? 1 : 0;
|
||||||
sfPan.connect(sfRouteGain);
|
sfPan.connect(sfRouteGain);
|
||||||
@@ -2618,6 +2675,7 @@ const WaveformLane = ({
|
|||||||
// Check if hovering near right edge of a clip for time-stretching (Alt key required)
|
// Check if hovering near right edge of a clip for time-stretching (Alt key required)
|
||||||
const toleranceSec = 8 / zoom;
|
const toleranceSec = 8 / zoom;
|
||||||
const rightEdgeClip = clips.find(c => {
|
const rightEdgeClip = clips.find(c => {
|
||||||
|
if (!c.buffer) return false;
|
||||||
const duration = c.buffer.duration / (c.speed || 1.0);
|
const duration = c.buffer.duration / (c.speed || 1.0);
|
||||||
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
||||||
});
|
});
|
||||||
@@ -2668,7 +2726,7 @@ const WaveformLane = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
const hoveredClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||||
const isOverClip = !!hoveredClip;
|
const isOverClip = !!hoveredClip;
|
||||||
if (activeTool === 'pen') {
|
if (activeTool === 'pen') {
|
||||||
canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed';
|
canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed';
|
||||||
@@ -2749,6 +2807,7 @@ const WaveformLane = ({
|
|||||||
// Check if time-stretching (Alt + Right Edge)
|
// Check if time-stretching (Alt + Right Edge)
|
||||||
const toleranceSec = 8 / zoom;
|
const toleranceSec = 8 / zoom;
|
||||||
const rightEdgeClip = clips.find(c => {
|
const rightEdgeClip = clips.find(c => {
|
||||||
|
if (!c.buffer) return false;
|
||||||
const duration = c.buffer.duration / (c.speed || 1.0);
|
const duration = c.buffer.duration / (c.speed || 1.0);
|
||||||
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
||||||
});
|
});
|
||||||
@@ -2823,7 +2882,7 @@ const WaveformLane = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||||
|
|
||||||
// Set selected clip ID
|
// Set selected clip ID
|
||||||
if (clickedClip) {
|
if (clickedClip) {
|
||||||
@@ -2965,7 +3024,7 @@ const WaveformLane = ({
|
|||||||
if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.id);
|
if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||||
if (clickedClip) {
|
if (clickedClip) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -6977,6 +7036,93 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Humanize: ngẫu nhiên hóa velocity + timing theo cường độ ──
|
||||||
|
const [humanizeStrength, setHumanizeStrength] = React.useState(0.10); // 0.05 nhẹ / 0.10 vừa / 0.18 mạnh
|
||||||
|
const applyHumanize = React.useCallback(() => {
|
||||||
|
if (!notes || !notes.length) { showToast('Không có nốt nào để humanize.', 'warning'); return; }
|
||||||
|
const velAmt = humanizeStrength;
|
||||||
|
const timeAmt = humanizeStrength * 0.15; // ±0.015 beat @ vừa (~12ms @120bpm)
|
||||||
|
pushToUndo(notes);
|
||||||
|
setNotes(prev => (prev || []).map(n => ({
|
||||||
|
...n,
|
||||||
|
velocity: Math.max(0.05, Math.min(1.0, (n.velocity || 0.8) + (Math.random() * 2 - 1) * velAmt)),
|
||||||
|
start_beat: Math.max(0, (n.start_beat || 0) + (Math.random() * 2 - 1) * timeAmt)
|
||||||
|
})));
|
||||||
|
showToast('Đã humanize ' + notes.length + ' nốt (velocity ±' + Math.round(velAmt * 100) + '%, timing ±' + Math.round(timeAmt * 1000) + 'ms).', 'success');
|
||||||
|
}, [notes, pushToUndo, setNotes, showToast, humanizeStrength]);
|
||||||
|
|
||||||
|
// ── Transpose semitone: dịch pitch tất cả nốt (clamp 0-127) ──
|
||||||
|
const applyTranspose = React.useCallback((semi) => {
|
||||||
|
const s = parseInt(semi);
|
||||||
|
if (isNaN(s) || s === 0) { showToast('Nhập số semitone khác 0.', 'warning'); return; }
|
||||||
|
if (!notes || !notes.length) { showToast('Không có nốt nào để transpose.', 'warning'); return; }
|
||||||
|
pushToUndo(notes);
|
||||||
|
setNotes(prev => (prev || []).map(n => ({
|
||||||
|
...n,
|
||||||
|
pitch: Math.max(0, Math.min(127, (n.pitch || 60) + s))
|
||||||
|
})));
|
||||||
|
showToast('Đã transpose ' + notes.length + ' nốt ' + (s > 0 ? '+' : '') + s + ' semitone.', 'success');
|
||||||
|
}, [notes, pushToUndo, setNotes, showToast]);
|
||||||
|
|
||||||
|
// ── Transpose theo SCALE (chuyển giọng): detect key hiện tại → map degree ──
|
||||||
|
const SCALE_PATTERNS = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10] };
|
||||||
|
const SCALE_ROOTS = { C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11 };
|
||||||
|
const detectKey = React.useCallback((noteList) => {
|
||||||
|
const roots = Object.keys(SCALE_ROOTS);
|
||||||
|
let best = null, bestScore = -1;
|
||||||
|
for (let ri = 0; ri < roots.length; ri++) {
|
||||||
|
for (const st of ['major', 'minor']) {
|
||||||
|
const tones = new Set(SCALE_PATTERNS[st].map(s => (SCALE_ROOTS[roots[ri]] + s) % 12));
|
||||||
|
let score = 0;
|
||||||
|
(noteList || []).forEach(n => { const pc = (((n.pitch || 60) % 12) + 12) % 12; if (tones.has(pc)) score++; });
|
||||||
|
if (score > bestScore) { bestScore = score; best = { root: roots[ri], scale: st }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best || { root: 'C', scale: 'major' };
|
||||||
|
}, []);
|
||||||
|
const [keyTargetRoot, setKeyTargetRoot] = React.useState('C');
|
||||||
|
const [keyTargetScale, setKeyTargetScale] = React.useState('major');
|
||||||
|
// Auto-detect scale khi MỞ midi item → hiển thị ở dropdown chuyển giọng.
|
||||||
|
// Key theo st.target_id (item id) — sửa note cùng item KHÔNG reset lựa chọn
|
||||||
|
// của user; mở item khác → detect lại.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const itemNotes = st.notes || [];
|
||||||
|
if (itemNotes.length) {
|
||||||
|
const k = detectKey(itemNotes);
|
||||||
|
setKeyTargetRoot(k.root);
|
||||||
|
setKeyTargetScale(k.scale);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [st.target_id]);
|
||||||
|
const applyTransposeToKey = React.useCallback(() => {
|
||||||
|
if (!notes || !notes.length) { showToast('Không có nốt nào để chuyển giọng.', 'warning'); return; }
|
||||||
|
const srcKey = detectKey(notes);
|
||||||
|
const dstKey = { root: keyTargetRoot, scale: keyTargetScale };
|
||||||
|
if (srcKey.root === dstKey.root && srcKey.scale === dstKey.scale) {
|
||||||
|
showToast('Đã ở giọng ' + dstKey.root + ' ' + dstKey.scale + ' rồi.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const srcTones = SCALE_PATTERNS[srcKey.scale].map(s => (SCALE_ROOTS[srcKey.root] + s) % 12);
|
||||||
|
const dstTones = SCALE_PATTERNS[dstKey.scale].map(s => (SCALE_ROOTS[dstKey.root] + s) % 12);
|
||||||
|
pushToUndo(notes);
|
||||||
|
setNotes(prev => (prev || []).map(n => {
|
||||||
|
const p = n.pitch || 60;
|
||||||
|
const pc = ((p % 12) + 12) % 12;
|
||||||
|
// Degree gần nhất trong scale nguồn (7 bậc)
|
||||||
|
let bestIdx = 0, bestDist = 99;
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
let d = Math.abs(pc - srcTones[i]); if (d > 6) d = 12 - d;
|
||||||
|
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||||
|
}
|
||||||
|
let shift = dstTones[bestIdx] - srcTones[bestIdx];
|
||||||
|
if (shift > 6) shift -= 12; else if (shift < -6) shift += 12;
|
||||||
|
return { ...n, pitch: Math.max(0, Math.min(127, p + shift)) };
|
||||||
|
}));
|
||||||
|
showToast('Chuyển giọng ' + srcKey.root + ' ' + srcKey.scale + ' → ' + dstKey.root + ' ' + dstKey.scale + ' (' + notes.length + ' nốt).', 'success');
|
||||||
|
}, [notes, pushToUndo, setNotes, showToast, detectKey, keyTargetRoot, keyTargetScale]);
|
||||||
|
|
||||||
|
const [transposeSemis, setTransposeSemis] = React.useState(0);
|
||||||
|
|
||||||
const handleUndo = React.useCallback(() => {
|
const handleUndo = React.useCallback(() => {
|
||||||
const prev = undoStackRef.current.pop();
|
const prev = undoStackRef.current.pop();
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
@@ -7681,14 +7827,16 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
try { previewNodesRef.current.gain.disconnect(); } catch(e) {}
|
try { previewNodesRef.current.gain.disconnect(); } catch(e) {}
|
||||||
previewNodesRef.current = null;
|
previewNodesRef.current = null;
|
||||||
}
|
}
|
||||||
if (window.SonicSF && window.SonicSF._playNoteFallback) {
|
if (window.SonicSF && window.SonicSF.playNote) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||||
var dwCh = dwTrk ? assignTrackMidiChannel(dwTrk, activeTracks) : 0;
|
var dwCh = dwTrk ? assignTrackMidiChannel(dwTrk, activeTracks) : 0;
|
||||||
var dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch;
|
var dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch;
|
||||||
var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000));
|
var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000));
|
||||||
var dwNodes = window.SonicSF._playNoteFallback(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
// playNote (FluidSynth — nhạc cụ THẬT của track). _playNoteFallback chỉ
|
||||||
if (dwNodes) previewNodesRef.current = dwNodes;
|
// là oscillator beep (sai âm với percussion/soundfont — user: note vẽ
|
||||||
|
// mới nghe nhạc cụ track trước).
|
||||||
|
window.SonicSF.playNote(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -7777,13 +7925,14 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
|
|
||||||
function playDrawPreview(p, durMs) {
|
function playDrawPreview(p, durMs) {
|
||||||
stopPreviewNote();
|
stopPreviewNote();
|
||||||
if (window.SonicSF && window.SonicSF._playNoteFallback) {
|
if (window.SonicSF && window.SonicSF.playNote) {
|
||||||
var pvCtx = getAudioContext();
|
var pvCtx = getAudioContext();
|
||||||
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
|
||||||
var pvCtxInst = resolveTrackInstrumentCtx(pvTrk, activeTracks);
|
var pvCtxInst = resolveTrackInstrumentCtx(pvTrk, activeTracks);
|
||||||
var pvVel = Math.round(brushVelocityRef.current * 127);
|
var pvVel = Math.round(brushVelocityRef.current * 127);
|
||||||
var pvNodes = window.SonicSF._playNoteFallback(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator
|
||||||
if (pvNodes) previewNodesRef.current = pvNodes;
|
// beep sai âm (percussion/soundfont).
|
||||||
|
window.SonicSF.playNote(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
|
||||||
previewPitchRef.current = p;
|
previewPitchRef.current = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8325,9 +8474,9 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
|||||||
return React.createElement("div", {
|
return React.createElement("div", {
|
||||||
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
|
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
|
||||||
},
|
},
|
||||||
/* 1. TOOLBAR HEADER */
|
/* 1. TOOLBAR HEADER — 2 hàng (wrap tự nhiên; spacer 100% ép hàng mới) */
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
|
className: "bg-[#282828] border-b border-zinc-900 flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5 shrink-0 text-slate-200"
|
||||||
}, React.createElement("div", {
|
}, React.createElement("div", {
|
||||||
className: "flex items-center gap-4"
|
className: "flex items-center gap-4"
|
||||||
}, React.createElement("select", {
|
}, React.createElement("select", {
|
||||||
@@ -8413,12 +8562,56 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
|||||||
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
|
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
|
||||||
}(),
|
}(),
|
||||||
title: "Toggle ghost notes visibility"
|
title: "Toggle ghost notes visibility"
|
||||||
}, "\uD83D\uDC7B Ghost"), React.createElement("div", {
|
}, "👻 MIDI ghost notes"), React.createElement("button", {
|
||||||
className: "flex items-center gap-1"
|
onClick: onClose,
|
||||||
|
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition ml-auto"
|
||||||
|
}, React.createElement("i", {
|
||||||
|
"data-lucide": "x",
|
||||||
|
className: "w-3 h-3"
|
||||||
|
}), "Đóng"), React.createElement("div", {
|
||||||
|
style: { flexBasis: "100%", height: 0 }
|
||||||
|
}), React.createElement("button", {
|
||||||
|
onClick: applyHumanize,
|
||||||
|
className: "px-2 py-1 rounded text-xs bg-amber-900/40 text-amber-300 border border-amber-700/60 hover:bg-amber-800/50 transition",
|
||||||
|
title: "Humanize: randomize velocity + timing"
|
||||||
|
}, "\uD83C\uDF9A Humanize"), React.createElement("select", {
|
||||||
|
key: "humstr", value: humanizeStrength,
|
||||||
|
onChange: function(e) { setHumanizeStrength(parseFloat(e.target.value)); },
|
||||||
|
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||||
|
title: "Cường độ humanize"
|
||||||
|
}, React.createElement("option", { key: "l", value: 0.05 }, "Nh\u1EB9"), React.createElement("option", { key: "m", value: 0.10 }, "V\u1EEBa"), React.createElement("option", { key: "s", value: 0.18 }, "M\u1EA1nh")), React.createElement("div", {
|
||||||
|
key: "transpose", className: "flex items-center gap-1"
|
||||||
|
}, React.createElement("input", {
|
||||||
|
key: "in", type: "number", step: 1, min: -24, max: 24, value: transposeSemis,
|
||||||
|
onChange: function(e) { setTransposeSemis(e.target.value); },
|
||||||
|
className: "w-12 px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-center text-zinc-200",
|
||||||
|
title: "Semitone offset (vd 2 = cao hơn 1 tone)"
|
||||||
|
}), React.createElement("button", {
|
||||||
|
key: "btn", onClick: function() { applyTranspose(transposeSemis); },
|
||||||
|
className: "px-2 py-1 rounded text-xs bg-sky-900/40 text-sky-300 border border-sky-700/60 hover:bg-sky-800/50 transition",
|
||||||
|
title: "Transpose all notes by the semitone offset"
|
||||||
|
}, "Transpose")), React.createElement("div", {
|
||||||
|
key: "keyshift", className: "flex items-center gap-1"
|
||||||
|
}, React.createElement("select", {
|
||||||
|
key: "root", value: keyTargetRoot,
|
||||||
|
onChange: function(e) { setKeyTargetRoot(e.target.value); },
|
||||||
|
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||||
|
title: "Giọng đích (root)"
|
||||||
|
}, ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"].map(function(r) { return React.createElement("option", { key: r, value: r }, r); })), React.createElement("select", {
|
||||||
|
key: "scale", value: keyTargetScale,
|
||||||
|
onChange: function(e) { setKeyTargetScale(e.target.value); },
|
||||||
|
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||||
|
title: "Thể scale đích"
|
||||||
|
}, React.createElement("option", { key: "maj", value: "major" }, "major"), React.createElement("option", { key: "min", value: "minor" }, "minor")), React.createElement("button", {
|
||||||
|
key: "btn", onClick: applyTransposeToKey,
|
||||||
|
className: "px-2 py-1 rounded text-xs bg-violet-900/40 text-violet-300 border border-violet-700/60 hover:bg-violet-800/50 transition",
|
||||||
|
title: "Chuyển giọng: map degree hiện tại sang giọng đích (auto-detect key nguồn)"
|
||||||
|
}, "🎵 Chuyển giọng")), React.createElement("div", {
|
||||||
|
className: "flex items-center gap-1 ml-auto"
|
||||||
}, React.createElement("button", {
|
}, React.createElement("button", {
|
||||||
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
|
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
|
||||||
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||||
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), React.createElement("button", {
|
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const ppq = 480;
|
const ppq = 480;
|
||||||
const bpmNum = parseInt(bpm) || 120;
|
const bpmNum = parseInt(bpm) || 120;
|
||||||
@@ -8468,13 +8661,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
|||||||
showToast('Đã xuất file MIDI!', 'success');
|
showToast('Đã xuất file MIDI!', 'success');
|
||||||
},
|
},
|
||||||
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||||
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export"), React.createElement("button", {
|
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"))),
|
||||||
onClick: onClose,
|
|
||||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition"
|
|
||||||
}, React.createElement("i", {
|
|
||||||
"data-lucide": "x",
|
|
||||||
className: "w-3 h-3"
|
|
||||||
}), "Đóng"))),
|
|
||||||
|
|
||||||
/* 2. BAR RULER */
|
/* 2. BAR RULER */
|
||||||
React.createElement("div", {
|
React.createElement("div", {
|
||||||
@@ -14475,9 +14662,10 @@ const App = () => {
|
|||||||
list.forEach(t => {
|
list.forEach(t => {
|
||||||
// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes
|
// ♪ state → SF mastering route (sfRouteGain/sfDryGain), live on existing nodes
|
||||||
const sn = activeTrackNodesRef.current[t.id];
|
const sn = activeTrackNodesRef.current[t.id];
|
||||||
if (sn && sn.sfRouteGain && sn.sfDryGain && (sn.sfRouteGain.gain.value > 0) !== !trackMidiBypassMap[t.id]) {
|
if (sn && sn.sfRouteGain && sn.sfDryGain && (sn.sfRouteGain.gain.value > 0) !== !effMidiBypass(t)) {
|
||||||
sn.sfRouteGain.gain.value = trackMidiBypassMap[t.id] ? 0 : 1;
|
const _b = effMidiBypass(t);
|
||||||
sn.sfDryGain.gain.value = trackMidiBypassMap[t.id] ? 1 : 0;
|
sn.sfRouteGain.gain.value = _b ? 0 : 1;
|
||||||
|
sn.sfDryGain.gain.value = _b ? 1 : 0;
|
||||||
}
|
}
|
||||||
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
|
const sig = (t.muted ? '1' : '0') + (t.solo ? '1' : '0') + ':' + (t.volumeDb ?? 0);
|
||||||
if (trackMuteSoloSigRef.current[t.id] === sig) return;
|
if (trackMuteSoloSigRef.current[t.id] === sig) return;
|
||||||
@@ -14507,8 +14695,22 @@ const App = () => {
|
|||||||
updateSfRouting();
|
updateSfRouting();
|
||||||
}, [tracks, sessionTabs]);
|
}, [tracks, sessionTabs]);
|
||||||
|
|
||||||
|
const prevActiveTabRef = useRef(activeTab);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const prevTab = prevActiveTabRef.current;
|
||||||
|
prevActiveTabRef.current = activeTab;
|
||||||
updateSfRouting();
|
updateSfRouting();
|
||||||
|
// Tab DEACTIVE → stop âm của tab đó (user requirement): rời khỏi sub-tab
|
||||||
|
// (PIANO_ROLL/section/audio tab) đang play → stop playback của tab đó.
|
||||||
|
// Tab mới hoạt động bình thường; MAIN giữ hành vi cũ (mở piano-roll lúc
|
||||||
|
// main play → main tiếp tục — handleEditMidiInTab đã xử lý).
|
||||||
|
if (prevTab !== activeTab) {
|
||||||
|
const prevSub = subTabsRef.current.find(s => s.id === prevTab);
|
||||||
|
if (prevSub && prevSub.isPlaying) {
|
||||||
|
try { stopAllPlayback(); } catch (e) {}
|
||||||
|
setSubTabs(prev => prev.map(s => s.id === prevTab ? { ...s, isPlaying: false } : s));
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||||
@@ -14586,6 +14788,28 @@ const App = () => {
|
|||||||
if (audioCtx && masterBus) {
|
if (audioCtx && masterBus) {
|
||||||
toggleMasteringOnMaster(masteringSettings.masterConnected, masteringSettings.isBypassed);
|
toggleMasteringOnMaster(masteringSettings.masterConnected, masteringSettings.isBypassed);
|
||||||
applyMasteringSettings(masteringSettings);
|
applyMasteringSettings(masteringSettings);
|
||||||
|
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
|
||||||
|
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
|
||||||
|
// ngay lên node đang phát (không chờ tracks effect).
|
||||||
|
try {
|
||||||
|
const _list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||||
|
_list.forEach(_t => {
|
||||||
|
const _n = activeTrackNodesRef.current[_t.id];
|
||||||
|
if (_n) {
|
||||||
|
if (_n.sfRouteGain && _n.sfDryGain) {
|
||||||
|
const _b = effMidiBypass(_t);
|
||||||
|
_n.sfRouteGain.gain.value = _b ? 0 : 1;
|
||||||
|
_n.sfDryGain.gain.value = _b ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (_n.route && _n.route.routeGain && _n.route.dryGain) {
|
||||||
|
const _ab = effAudioBypass(_t);
|
||||||
|
_n.route.routeGain.gain.value = _ab ? 0 : 1;
|
||||||
|
_n.route.dryGain.gain.value = _ab ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSfRouting();
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}, [masteringSettings]);
|
}, [masteringSettings]);
|
||||||
|
|
||||||
@@ -14678,10 +14902,27 @@ const App = () => {
|
|||||||
if (!pendingWasNull) return;
|
if (!pendingWasNull) return;
|
||||||
|
|
||||||
var lastId = localStorage.getItem('sonic_project_id');
|
var lastId = localStorage.getItem('sonic_project_id');
|
||||||
if (!lastId) return;
|
|
||||||
var lastName = localStorage.getItem('sonic_project_name') || 'Dự án';
|
var lastName = localStorage.getItem('sonic_project_name') || 'Dự án';
|
||||||
try {
|
|
||||||
var parsed = null;
|
var parsed = null;
|
||||||
|
if (!lastId) {
|
||||||
|
// Máy mới / browser ẩn danh: localStorage TRỐNG (id/name không tồn tại
|
||||||
|
// trên máy này) → tự mở project Cloud GẦN NHẤT của tài khoản đang đăng
|
||||||
|
// nhập — project tạo trên máy khác vẫn mở được ngay.
|
||||||
|
const prof = currentUser || (function () { try { return JSON.parse(localStorage.getItem('sonic_user') || 'null'); } catch (e) { return null; } })();
|
||||||
|
if (prof && window.SonicAPI && window.SonicAPI.listCloudProjects) {
|
||||||
|
try {
|
||||||
|
const cloudList = await window.SonicAPI.listCloudProjects();
|
||||||
|
if (cloudList && cloudList.length > 0) {
|
||||||
|
const latest = cloudList[0]; // backend ORDER BY updated_at DESC
|
||||||
|
lastId = latest.id;
|
||||||
|
lastName = latest.name || lastName;
|
||||||
|
const proj = await window.SonicAPI.getCloudProject(lastId);
|
||||||
|
if (proj && proj.data_json) parsed = JSON.parse(proj.data_json);
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('restore cloud fallback error:', e); }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
if (lastId.startsWith('local_')) {
|
if (lastId.startsWith('local_')) {
|
||||||
var localData = localStorage.getItem('sonic_local_project_data');
|
var localData = localStorage.getItem('sonic_local_project_data');
|
||||||
if (localData) parsed = JSON.parse(localData);
|
if (localData) parsed = JSON.parse(localData);
|
||||||
@@ -14689,6 +14930,9 @@ const App = () => {
|
|||||||
var proj = await window.SonicAPI.getCloudProject(lastId);
|
var proj = await window.SonicAPI.getCloudProject(lastId);
|
||||||
if (proj) parsed = JSON.parse(proj.data_json);
|
if (proj) parsed = JSON.parse(proj.data_json);
|
||||||
}
|
}
|
||||||
|
} catch (e) { console.warn('restore load error:', e); }
|
||||||
|
}
|
||||||
|
try {
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
var restoredBpm = bpm;
|
var restoredBpm = bpm;
|
||||||
var restoredTracks = [];
|
var restoredTracks = [];
|
||||||
@@ -17734,16 +17978,22 @@ const App = () => {
|
|||||||
// sẽ rebuild loop + panic hủy notes chờ → CÂM TOÀN CỤC → exempt hoàn toàn
|
// sẽ rebuild loop + panic hủy notes chờ → CÂM TOÀN CỤC → exempt hoàn toàn
|
||||||
// (các fix setValueAtTime/NaN guard đã hết "state is bad" — watchdog chỉ
|
// (các fix setValueAtTime/NaN guard đã hết "state is bad" — watchdog chỉ
|
||||||
// còn là lớp cứu cuối cho main/audio-tab).
|
// còn là lớp cứu cuối cho main/audio-tab).
|
||||||
|
// PIANO_ROLL: watchdog CHỈ rebuild khi output chứa NaN (chain chết —
|
||||||
|
// state-bad). KHÔNG rebuild khi im lặng thường (rests tự nhiên giữa các
|
||||||
|
// note — false-positive = stopAllPlayback + reschedule = glitch).
|
||||||
const _anySubPlaying = subTabsRef.current.some(s => s.isPlaying);
|
const _anySubPlaying = subTabsRef.current.some(s => s.isPlaying);
|
||||||
const activeSub = subTabsRef.current.find(s => s.id === activeTabRef.current);
|
const activeSub = subTabsRef.current.find(s => s.id === activeTabRef.current);
|
||||||
const isPianoRoll = activeSub && activeSub.type === 'PIANO_ROLL';
|
const isPianoRoll = activeSub && activeSub.type === 'PIANO_ROLL';
|
||||||
if (!isPianoRoll && (isPlaying || _anySubPlaying) && masterBus && masterBus.analyser && activeSourcesRef.current.length > 0) {
|
if ((isPlaying || _anySubPlaying) && masterBus && masterBus.analyser && (isPianoRoll || activeSourcesRef.current.length > 0)) {
|
||||||
try {
|
try {
|
||||||
// "Đáng lẽ đang có âm" — quyết định watchdog có được rebuild không:
|
// "Đáng lẽ đang có âm" — quyết định watchdog có được rebuild không:
|
||||||
// - Main / sub-tab audio: source thật đang trong khoảng phát.
|
// - Main / sub-tab audio: source thật đang trong khoảng phát.
|
||||||
|
// - PIANO_ROLL: tab đang play (notes đã schedule) = đáng lẽ có âm.
|
||||||
const ctxNow = getAudioContext().currentTime;
|
const ctxNow = getAudioContext().currentTime;
|
||||||
let anyPlaying = false;
|
let anyPlaying = false;
|
||||||
if (_anySubPlaying) {
|
if (isPianoRoll) {
|
||||||
|
anyPlaying = _anySubPlaying;
|
||||||
|
} else if (_anySubPlaying) {
|
||||||
const _subs = subTabsRef.current || [];
|
const _subs = subTabsRef.current || [];
|
||||||
for (let _si = 0; _si < _subs.length; _si++) {
|
for (let _si = 0; _si < _subs.length; _si++) {
|
||||||
const s = _subs[_si];
|
const s = _subs[_si];
|
||||||
@@ -17760,10 +18010,28 @@ const App = () => {
|
|||||||
masterBus.analyser.getByteTimeDomainData(d);
|
masterBus.analyser.getByteTimeDomainData(d);
|
||||||
let pk = 0;
|
let pk = 0;
|
||||||
for (let i = 0; i < d.length; i++) { const v = Math.abs(d[i] - 128) / 128; if (v > pk) pk = v; }
|
for (let i = 0; i < d.length; i++) { const v = Math.abs(d[i] - 128) / 128; if (v > pk) pk = v; }
|
||||||
if (pk < 0.001) {
|
// PIANO_ROLL: chain chết xuất NaN → getByteTimeDomainData đọc NaN →
|
||||||
|
// byte 0/128 → pk CAO → mù. Check float data (chỉ piano roll — rests
|
||||||
|
// tự nhiên thì KHÔNG rebuild).
|
||||||
|
let nanOut = false;
|
||||||
|
if (isPianoRoll) {
|
||||||
|
try {
|
||||||
|
const f = new Float32Array(128);
|
||||||
|
masterBus.analyser.getFloatTimeDomainData(f);
|
||||||
|
for (let i = 0; i < f.length; i++) { if (!isFinite(f[i])) { nanOut = true; break; } }
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// PIANO_ROLL: CHỈ rebuild khi output NaN (chain chết — state-bad).
|
||||||
|
// pk<0.001 (im lặng) KHÔNG trigger cho piano roll — rests tự nhiên
|
||||||
|
// giữa các note > 750ms là BÌNH THƯỜNG → false-positive = recovery
|
||||||
|
// hủy play + restart notes (glitch) — đúng chuỗi log recovery trước.
|
||||||
|
if ((isPianoRoll ? nanOut : (pk < 0.001 || nanOut))) {
|
||||||
masterSilenceFramesRef.current++;
|
masterSilenceFramesRef.current++;
|
||||||
const sinceRebuild = performance.now() - (lastMasterRebuildTimeRef.current || 0);
|
const sinceRebuild = performance.now() - (lastMasterRebuildTimeRef.current || 0);
|
||||||
if (masterSilenceFramesRef.current > 45 && sinceRebuild > 3000) {
|
// NaN = chain CHẾT chắc chắn → rebuild NGAY (3 frame ≈ 50ms).
|
||||||
|
// pk<0.001 (im lặng nghi ngờ — main) giữ 45 frame (750ms) để loại
|
||||||
|
// transient gap false-positive. Cooldown 3000 chống rebuild-loop.
|
||||||
|
if (masterSilenceFramesRef.current > (nanOut ? 3 : 45) && sinceRebuild > 3000) {
|
||||||
masterSilenceFramesRef.current = 0;
|
masterSilenceFramesRef.current = 0;
|
||||||
lastMasterRebuildTimeRef.current = performance.now();
|
lastMasterRebuildTimeRef.current = performance.now();
|
||||||
console.warn('[Recovery] Master silent while sources active — rebuilding audio graph');
|
console.warn('[Recovery] Master silent while sources active — rebuilding audio graph');
|
||||||
@@ -17772,7 +18040,18 @@ const App = () => {
|
|||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
Object.keys(activeTrackNodesRef.current).forEach(k => { const n = activeTrackNodesRef.current[k]; try { if (n && n.gainNode && n.gainNode.disconnect) n.gainNode.disconnect(); } catch (e) {} });
|
Object.keys(activeTrackNodesRef.current).forEach(k => { const n = activeTrackNodesRef.current[k]; try { if (n && n.gainNode && n.gainNode.disconnect) n.gainNode.disconnect(); } catch (e) {} });
|
||||||
activeTrackNodesRef.current = {};
|
activeTrackNodesRef.current = {};
|
||||||
try { initMasterBus(getAudioContext()); } catch (e) { console.warn('[Recovery] initMasterBus error:', e); }
|
// Tháo chain cũ khỏi destination rồi rebuild THẬT — initMasterBus
|
||||||
|
// early-return khi masterBus còn tồn tại → recovery trước đây là
|
||||||
|
// no-op → chain "state is bad" bị STUCK vĩnh viễn.
|
||||||
|
try {
|
||||||
|
if (masterBus) {
|
||||||
|
try { if (masterBus.analyser) masterBus.analyser.disconnect(); } catch (e) {}
|
||||||
|
try { if (masterBus.output) masterBus.output.disconnect(); } catch (e) {}
|
||||||
|
try { if (masterBus.dryOutput) masterBus.dryOutput.disconnect(); } catch (e) {}
|
||||||
|
}
|
||||||
|
masterBus = null;
|
||||||
|
initMasterBus(getAudioContext());
|
||||||
|
} catch (e) { console.warn('[Recovery] initMasterBus error:', e); }
|
||||||
// Resume ĐÚNG chế độ play hiện tại (main hoặc sub-tab piano roll)
|
// Resume ĐÚNG chế độ play hiện tại (main hoặc sub-tab piano roll)
|
||||||
const curTab = activeTabRef.current;
|
const curTab = activeTabRef.current;
|
||||||
const subSt = subTabsRef.current.find(s => s.id === curTab);
|
const subSt = subTabsRef.current.find(s => s.id === curTab);
|
||||||
@@ -18142,7 +18421,7 @@ const App = () => {
|
|||||||
// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX
|
// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX
|
||||||
// Rack modules are still applied — PWR controls those). sfRouteGain →
|
// Rack modules are still applied — PWR controls those). sfRouteGain →
|
||||||
// masterBus.input (mastering), sfDryGain → dry bus (skip mastering).
|
// masterBus.input (mastering), sfDryGain → dry bus (skip mastering).
|
||||||
const sfBypass = trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass);
|
const sfBypass = effMidiBypass(track);
|
||||||
sfRouteGain = context.createGain();
|
sfRouteGain = context.createGain();
|
||||||
sfDryGain = context.createGain();
|
sfDryGain = context.createGain();
|
||||||
sfRouteGain.gain.value = sfBypass ? 0 : 1;
|
sfRouteGain.gain.value = sfBypass ? 0 : 1;
|
||||||
@@ -18248,6 +18527,14 @@ const App = () => {
|
|||||||
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
if (_prNode.sfEntry) {
|
if (_prNode.sfEntry) {
|
||||||
window.SonicSF.setOutputDestination(_prNode.sfEntry);
|
window.SonicSF.setOutputDestination(_prNode.sfEntry);
|
||||||
|
// Ép route qua mastering chain NGAY tại thời điểm routing (node có
|
||||||
|
// thể tạo khi mastering OFF → route dry — sửa ngay nếu chain ON).
|
||||||
|
if (_prNode.sfRouteGain && _prNode.sfDryGain) {
|
||||||
|
const _prTrk = list.find(t => t.id === _activeSub.trackId);
|
||||||
|
const _b = effMidiBypass(_prTrk || { id: _activeSub.trackId });
|
||||||
|
_prNode.sfRouteGain.gain.value = _b ? 0 : 1;
|
||||||
|
_prNode.sfDryGain.gain.value = _b ? 1 : 0;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_prNode.gainNode) {
|
if (_prNode.gainNode) {
|
||||||
@@ -18265,6 +18552,12 @@ const App = () => {
|
|||||||
// sfDryGain) to skip or include the Mastering FX Chain.
|
// sfDryGain) to skip or include the Mastering FX Chain.
|
||||||
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
|
if (node && node.sfEntry && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
window.SonicSF.setOutputDestination(node.sfEntry);
|
window.SonicSF.setOutputDestination(node.sfEntry);
|
||||||
|
// Ép route qua mastering chain NGAY tại thời điểm routing.
|
||||||
|
if (node.sfRouteGain && node.sfDryGain) {
|
||||||
|
const _b = effMidiBypass(t.id);
|
||||||
|
node.sfRouteGain.gain.value = _b ? 0 : 1;
|
||||||
|
node.sfDryGain.gain.value = _b ? 1 : 0;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||||
@@ -21594,6 +21887,15 @@ const App = () => {
|
|||||||
setProjectName(finalName);
|
setProjectName(finalName);
|
||||||
window.SonicStorage.exportProjectToSFS(projectSchemaObj);
|
window.SonicStorage.exportProjectToSFS(projectSchemaObj);
|
||||||
showToast(`Đã lưu dự án local "${finalName}" thành công!`, "success");
|
showToast(`Đã lưu dự án local "${finalName}" thành công!`, "success");
|
||||||
|
// Đã đăng nhập → đồng bộ lên Cloud (fire-and-forget): project mở được từ
|
||||||
|
// máy khác / browser ẩn danh khi đăng nhập cùng tài khoản.
|
||||||
|
if (currentUser && window.SonicAPI && window.SonicAPI.saveCloudProject) {
|
||||||
|
window.SonicAPI.saveCloudProject(finalName, dataStr).then(function (res) {
|
||||||
|
if (res && res.project_id) {
|
||||||
|
console.log('[Cloud] local project synced:', res.project_id);
|
||||||
|
}
|
||||||
|
}).catch(function (e) { console.warn('Cloud sync local project failed:', e); });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveCloudProject = async (name, existingProjectId) => {
|
const handleSaveCloudProject = async (name, existingProjectId) => {
|
||||||
@@ -22911,6 +23213,11 @@ STRICT CONSTRAINTS:
|
|||||||
...(srcTrack || {}),
|
...(srcTrack || {}),
|
||||||
id: newTrackId,
|
id: newTrackId,
|
||||||
name: '[AI Var] ' + title,
|
name: '[AI Var] ' + title,
|
||||||
|
// KHÔNG kế thừa midiChannel của track gốc: nếu dùng chung channel,
|
||||||
|
// solo/mute track gốc gửi CC7=0 trên channel đó → clone (cùng
|
||||||
|
// channel) bị NHỎ/CÂM. ensureTrackMidiChannel/assignTrackMidiChannel
|
||||||
|
// sẽ cấp channel RIÊNG cho track mới.
|
||||||
|
midiChannel: undefined,
|
||||||
buffer: null,
|
buffer: null,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
clips: [],
|
clips: [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,9 @@
|
|||||||
let _activeOscillators = {};
|
let _activeOscillators = {};
|
||||||
let _gainNode = null;
|
let _gainNode = null;
|
||||||
let _pendingOutputDestination = null;
|
let _pendingOutputDestination = null;
|
||||||
|
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
|
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||||
|
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||||
let _scheduledNotes = [];
|
let _scheduledNotes = [];
|
||||||
let _loadPromises = {};
|
let _loadPromises = {};
|
||||||
let _sfloadSeq = 0;
|
let _sfloadSeq = 0;
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination));
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return _audioCtx;
|
return _audioCtx;
|
||||||
}
|
}
|
||||||
@@ -41,7 +45,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = ctx.createGain();
|
_gainNode = ctx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination));
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -54,7 +59,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = window.__sharedAudioCtx.createGain();
|
_gainNode = window.__sharedAudioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(_pendingOutputDestination || window.__sharedAudioCtx.destination);
|
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return window.__sharedAudioCtx;
|
return window.__sharedAudioCtx;
|
||||||
};
|
};
|
||||||
@@ -68,9 +74,17 @@
|
|||||||
setOutputDestination: function (node) {
|
setOutputDestination: function (node) {
|
||||||
try {
|
try {
|
||||||
if (_gainNode) {
|
if (_gainNode) {
|
||||||
_gainNode.disconnect();
|
|
||||||
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||||
|
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||||
|
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||||
|
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||||
|
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||||
|
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||||
|
// ngay sau setOutputDestination lần 2).
|
||||||
|
if (dest === _outputDestination) return;
|
||||||
|
_gainNode.disconnect();
|
||||||
_gainNode.connect(dest);
|
_gainNode.connect(dest);
|
||||||
|
_outputDestination = dest;
|
||||||
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||||
} else {
|
} else {
|
||||||
_pendingOutputDestination = node || null;
|
_pendingOutputDestination = node || null;
|
||||||
@@ -104,7 +118,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination));
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||||
@@ -140,7 +155,7 @@
|
|||||||
// expected notice when a soundfont simply has no
|
// expected notice when a soundfont simply has no
|
||||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||||
// font) — the note is just silent, not an error.
|
// font) — the note is just silent, not an error.
|
||||||
if (msg && msg.indexOf('No preset found on channel') !== -1) return;
|
if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
|
||||||
console.warn('[FluidSynth:err]', msg);
|
console.warn('[FluidSynth:err]', msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -191,17 +206,31 @@
|
|||||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||||
|
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||||
|
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||||
|
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||||
|
// buffer + re-malloc khi đổi.
|
||||||
|
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
spn.onaudioprocess = function (e) {
|
spn.onaudioprocess = function (e) {
|
||||||
var left = e.outputBuffer.getChannelData(0);
|
var left = e.outputBuffer.getChannelData(0);
|
||||||
var right = e.outputBuffer.getChannelData(1);
|
var right = e.outputBuffer.getChannelData(1);
|
||||||
var sz = left.length;
|
var sz = left.length;
|
||||||
try {
|
try {
|
||||||
|
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||||
|
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||||
|
lp = _fluidModule._malloc(sz * 4);
|
||||||
|
rp = _fluidModule._malloc(sz * 4);
|
||||||
|
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
|
}
|
||||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||||
var hf = _fluidModule.HEAPF32;
|
var hf = _fluidModule.HEAPF32;
|
||||||
var lpb = lp >> 2, rpb = rp >> 2;
|
var lpb = lp >> 2, rpb = rp >> 2;
|
||||||
for (var si = 0; si < sz; si++) {
|
for (var si = 0; si < sz; si++) {
|
||||||
left[si] = hf[lpb + si];
|
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||||
right[si] = hf[rpb + si];
|
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||||
|
var L = hf[lpb + si], R = hf[rpb + si];
|
||||||
|
left[si] = isFinite(L) ? L : 0;
|
||||||
|
right[si] = isFinite(R) ? R : 0;
|
||||||
}
|
}
|
||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
};
|
};
|
||||||
@@ -298,9 +327,16 @@
|
|||||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||||
var resp = await fetch(url);
|
var resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||||
|
// serve qua /soundfonts/{f} — catalog default-soundfonts).
|
||||||
|
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
|
||||||
|
var resp2 = await fetch(url2);
|
||||||
|
if (!resp2.ok) {
|
||||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
resp = resp2;
|
||||||
|
}
|
||||||
buf = await resp.arrayBuffer();
|
buf = await resp.arrayBuffer();
|
||||||
if (cache) await cache.saveBuffer(sfId, buf);
|
if (cache) await cache.saveBuffer(sfId, buf);
|
||||||
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
||||||
@@ -515,10 +551,24 @@
|
|||||||
// Quick instrument pick on a track does not pre-load it, so load
|
// Quick instrument pick on a track does not pre-load it, so load
|
||||||
// lazily here and retry the note once the font is ready.
|
// lazily here and retry the note once the font is ready.
|
||||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||||
|
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
|
||||||
|
// — note chạy thẳng fallback để CÓ ÂM.
|
||||||
|
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
|
||||||
|
if (Date.now() - _lastFail < 10000) {
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
||||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||||
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||||
if (ok) doNote();
|
if (ok) {
|
||||||
|
doNote();
|
||||||
|
} else {
|
||||||
|
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||||
|
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||||
|
_sfLoadFailAt[finalSfId] = Date.now();
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -535,7 +585,34 @@
|
|||||||
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||||
|
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||||
|
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||||
|
// định → font không có → cache channel = (128,0)
|
||||||
|
// INVALID → note sau skip re-select (progAlreadySet)
|
||||||
|
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||||
|
if (finalBank === 128) {
|
||||||
|
var _vKey = finalSfId || ('h' + sfHandle);
|
||||||
|
if (_validPercCache[_vKey] === undefined) {
|
||||||
|
var _found = null;
|
||||||
|
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||||
|
var _bk = _b === 0 ? 128 : 0;
|
||||||
|
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||||
|
try {
|
||||||
|
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||||
|
_found = [_bk, _p];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validPercCache[_vKey] = _found;
|
||||||
|
}
|
||||||
|
if (_validPercCache[_vKey]) {
|
||||||
|
finalBank = _validPercCache[_vKey][0];
|
||||||
|
finalProg = _validPercCache[_vKey][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||||
@@ -618,7 +695,11 @@
|
|||||||
var oscType = 'triangle';
|
var oscType = 'triangle';
|
||||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||||
var prog = program !== undefined ? parseInt(program) : 0;
|
var prog = program !== undefined ? parseInt(program) : 0;
|
||||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||||
|
// truyền — trước đây override program của track bằng cache channel
|
||||||
|
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||||
|
// nhạc cụ của track TRƯỚC).
|
||||||
|
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||||
prog = _channels[channel].program || prog;
|
prog = _channels[channel].program || prog;
|
||||||
}
|
}
|
||||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202608042158"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608060630"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<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/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608042300" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608061030" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1854,3 +1854,190 @@
|
|||||||
(2) **Chống NaN cho chương trình/bank FluidSynth**: Thêm bộ lọc `parseInt` và `isNaN` kiểm tra biến `usedBank` và `usedProg` bên trong hàm phát nốt `doNote` của `soundfontPlayer.js`. Tránh truyền giá trị `NaN` trực tiếp vào hàm WASM `_fluid_synth_program_select` có thể làm rối loạn bộ tổng hợp âm bên trong FluidSynth và kết xuất mẫu âm thanh NaN.
|
(2) **Chống NaN cho chương trình/bank FluidSynth**: Thêm bộ lọc `parseInt` và `isNaN` kiểm tra biến `usedBank` và `usedProg` bên trong hàm phát nốt `doNote` của `soundfontPlayer.js`. Tránh truyền giá trị `NaN` trực tiếp vào hàm WASM `_fluid_synth_program_select` có thể làm rối loạn bộ tổng hợp âm bên trong FluidSynth và kết xuất mẫu âm thanh NaN.
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
||||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||||
|
|
||||||
|
### [2026-08-05 22:30] Task: 2 fix quyết định trên baseline — NaN sweep synth (heap dangle) + watchdog piano-roll rebuild THẬT
|
||||||
|
- **Báo cáo user lặp lại (cùng text):** piano-roll play → BACK → play → CÂM, chain STUCK. Sau khi loại toàn bộ compressor (v22:00) mà vẫn lỗi → NaN KHÔNG từ compressor.
|
||||||
|
- **2 lỗ hổng còn lại (baseline cfc114b):**
|
||||||
|
(1) **Synth render KHÔNG sweep NaN + lp/rp dangle:** `_leftBufPtr/_rightBufPtr` malloc 1 lần ở init; load SGM-V2.01 (~300MB) → heap WASM realloc → pointer đọc vùng free → NaN → chain state-bad. FIX soundfontPlayer.js: theo dõi `HEAPU8.buffer` → re-malloc khi đổi + **NaN sweep (isFinite → 0)** ở mọi block — synth output KHÔNG BAO GIỜ chứa NaN.
|
||||||
|
(2) **Watchdog recovery là NO-OP:** `initMasterBus` early-return khi masterBus còn tồn tại → [Recovery] không rebuild gì → chain chết STUCK vĩnh viễn. FIX: teardown (disconnect analyser/output/dryOutput + masterBus=null) TRƯỚC initMasterBus → rebuild biquad THẬT + reschedule.
|
||||||
|
(3) **Watchdog mở cho PIANO_ROLL:** trước đây `!isPianoRoll` (exempt hoàn toàn). Giờ: piano-roll CHỈ rebuild khi output NaN (getFloatTimeDomainData + isFinite — rests tự nhiên KHÔNG trigger, tránh false-positive).
|
||||||
|
- **Các file ảnh hưởng:** soundfontPlayer.js, app.jsx, index.html (?v=202608052230 cho cả 2), wiki.md. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM (sweep chặn NaN tại nguồn + watchdog cứu nếu còn chết).
|
||||||
|
|
||||||
|
### [2026-08-05 23:30] Task: Trên commit user 55d3464 — áp lại fix compressor (default bypass + tanh/hard-clip limiters) — trị "âm nhỏ + chain chết"
|
||||||
|
- **User tự commit 55d3464 "FIX: Piano roll tab không xuất âm thanh qua mastering chain"** = cfc114b + giữ watchdog piano-roll + giữ NaN sweep + re-malloc của agent — NHƯNG compressor gốc VẪN CÒN.
|
||||||
|
- **Báo cáo user:** recovery có âm nhưng KHÔNG qua mastering → rất nhỏ.
|
||||||
|
- **2 lý do:**
|
||||||
|
(1) **Compressor mặc định (threshold -24, ratio 12, LUÔN-ON) vẫn trong path** (`input → compressor → inputAnalyser`) — pump-down tín hiệu → âm nhỏ + méo; và phát NaN trên bass transient → state-bad → chain chết.
|
||||||
|
(2) maximizerCompressor + limNode + track limiter vẫn là DynamicsCompressor — nguồn NaN.
|
||||||
|
- **Áp lại (trên HEAD 55d3464):** bypass compressor mặc định; maximizerCompressor → WaveShaper HARD CLIP tại ceiling (slope 1 — không boost); mastering limNode → tanh soft-clip; track 'limiter' → tanh; applyMasteringSettings adapt (_setCeiling/_setThreshold, OFF → identity).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052330), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM, âm lượng đủ (hết compressor pump-down), qua mastering.
|
||||||
|
|
||||||
|
### [2026-08-05 23:50] Task: Watchdog piano-roll FALSE POSITIVE — recovery trên rests tự nhiên (pk trigger) → NaN-ONLY
|
||||||
|
- **Log v2330 (compressor fix ĐÃ chạy):** recovery fire → rebuild → applyMasteringSettings → node mới → sfEntry → **noteon 23 notes từ 0:0 (bass 56/49/37 vel 127) — KHÔNG state-bad!!** ⇒ compressor fix HOẠT ĐỘNG (bass không còn NaN → chain không chết).
|
||||||
|
- **NHƯNG [Recovery] vẫn fire** — lỗi watchdog của agent: điều kiện `pk < 0.001 || (isPianoRoll && nanOut)` — **piano roll vẫn trigger bởi pk<0.001** — rest tự nhiên > 750ms trong pattern = FALSE POSITIVE → recovery hủy play + restart notes (glitch + "âm nhỏ" do restart mất bối cảnh).
|
||||||
|
- **FIX:** `(isPianoRoll ? nanOut : (pk < 0.001 || nanOut))` — piano roll CHỈ rebuild khi output NaN (chain chết). Main giữ nguyên (pk || nanOut).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052350), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → play piano roll từ 0:0 → KỲ VỌNG: CÓ ÂM liên tục, KHÔNG [Recovery] (trừ khi chain thực sự chết — NaN), không restart glitch.
|
||||||
|
|
||||||
|
### [2026-08-06 00:00] Task: TRIGGER THẬT cuối — routing swap DƯ giữa stream (dedupe setOutputDestination) + state-bad trên mastering PWR
|
||||||
|
- **Log v2350:** state-bad TRỞ LẠI — nổ NGAY SAU `setOutputDestination to: track node (sfEntry)` LẦN 2 (sau 6 noteon đầu — từ applyAllTrackMuteSolo cuối startSubTabPlayback) — dù compressor đã hết (bass play sạch ở các log khác). ⇒ **Graph mutation (disconnect/reconnect _gainNode GIỮA stream) làm ScriptProcessor xuất buffer uninitialized → NaN → 11 biquad sụp.** Compressor vô can. Khớp mọi log từ đầu: state-bad LUÔN sau swap lần 2; play 0:1 (swap xong trước notes) không nổ.
|
||||||
|
- **FIX (soundfontPlayer.js):** dedupe `setOutputDestination` — cache `_outputDestination` (5 chỗ khởi tạo) + `if (dest === _outputDestination) return;` — swap dư (sfEntry→sfEntry) bị loại; swap thật (masterBus.input→sfEntry — TRƯỚC notes) giữ nguyên.
|
||||||
|
- **Các file ảnh hưởng:** soundfontPlayer.js (?v=202608052355 — chỉ hard refresh, không cần build precompiled), wiki.md.
|
||||||
|
- **Ghi chú/Test:** hard refresh → bật mastering PWR → play piano roll từ 0:0 → KỲ VỌNG: KHÔNG state-bad, âm qua mastering (EQ/imager/maximizer nghe rõ), không recovery giả.
|
||||||
|
|
||||||
|
### [2026-08-06 00:00] Task: Chain FLAT sau recreation — stale _lastMasteringSig cache (spectrum hiển thị nhưng không xử lí)
|
||||||
|
- **Báo cáo user:** mastering chain spectrum hiển thị trong các module NHƯNG âm thanh không được xử lí (không tăng gain, không thay đổi).
|
||||||
|
- **Cơ chế:** `applyMasteringSettings` early-return qua `_lastMasteringSig` (module-level, PERSIST qua recreation masterBus). Sau recovery (watchdog rebuild: masterBus=null → initMasterBus) chain MỚI giữ giá trị INIT (EQ gain 0, imager width 100, maximizer boost 0) → **FLAT** — tín hiệu chảy qua modules (spectrum hiển thị) nhưng output = input. Cũng giải thích "âm rất nhỏ" sau recovery ở vòng trước (mất maximizer gain 5.4dB + EQ).
|
||||||
|
- **FIX (app.jsx initMasterBus):** `_lastMasteringSig = null;` trước `applyMasteringSettings` — chain mới được cấu hình lại đầy đủ.
|
||||||
|
- **Kết hợp với v23:55 (dedupe swap):** dedupe ngăn state-bad (không recovery → không flat); sig-reset đảm bảo recovery (nếu có) cấu hình lại chain — 2 fix bổ trợ.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060000), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → bật mastering PWR → play piano roll 0:0 → KỲ VỌNG: âm qua mastering ĐẦY ĐỦ (EQ gain nghe rõ, maximizer boost, imager width) — chỉnh slider module → âm thay đổi ngay.
|
||||||
|
|
||||||
|
### [2026-08-06 00:30] Task: Recovery NGAY khi NaN — fast-path 3 frame (~50ms) thay vì 45 frame (750ms)
|
||||||
|
- **Câu hỏi user:** "khi quay lại 0:0 mastering recovery — tại sao delay 1.5s mà không recovery ngay?"
|
||||||
|
- **Giải thích delay:** 2 ngưỡng cố ý: (1) 45 frame ≈ 750ms xác nhận im lặng THẬT (false-positive = stopAllPlayback + restart = glitch — thiết kế cho main session transient gap); (2) 3s cooldown chống rebuild-loop.
|
||||||
|
- **Fix (app.jsx updatePlayhead):** `if (masterSilenceFramesRef.current > (nanOut ? 3 : 45) && sinceRebuild > 3000)` — **NaN (chain chết chắc chắn) → rebuild sau 3 frame ≈ 50ms** (gần như tức thì); pk<0.001 (main) giữ 45 frame. Cooldown 3000 giữ nguyên (chống loop nếu collapse deterministic).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060030), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → play 0:0 → nếu chain chết: âm hồi phục ~150ms (thay vì 1.5s).
|
||||||
|
|
||||||
|
### [2026-08-06 01:00] Task: Percussion track câm — program_select bank 128 thiếu preset → fallback preset hợp lệ
|
||||||
|
- **Báo cáo user:** track percussion chỉ nghe 1 âm đầu rồi câm; bật channel 10 → `[FluidSynth:err] There is no preset with bank number 128 and preset number 0 in SoundFont 2`.
|
||||||
|
- **Cơ chế:** track "latin hand perc" (sfId) mang bank 128 prog 0; `fluid_synth_program_select` trả -1 (preset không tồn tại trong font) → noteon trên preset rỗng = CÂM; channel cache vẫn ghi (128,0) → noteon sau skip re-select (progAlreadySet) → CÂM tiếp ("1 âm đầu" = note đầu còn preset cũ hợp lệ trước khi cache bị ghi đè).
|
||||||
|
- **FIX (soundfontPlayer.js doNote):** kiểm tra `_selRet !== 0 && finalBank === 128` → **fallback chuỗi preset trống phổ biến [128,48 (GM kit) → 0,0 → 128,1 → 0,48]** — chọn preset đầu tiên select thành công (trả 0) + cập nhật channel cache (finalBank/finalProg) → các note sau dùng preset hợp lệ. Lọc thêm error "There is no preset with bank number" khỏi console (printErr).
|
||||||
|
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060100 — chỉ hard refresh, không build precompiled), `wiki.md`.
|
||||||
|
- **Ghi chú/Test:** hard refresh → play track percussion → KỲ VỌNG: mọi note đều kêu (fallback preset), hết error spam.
|
||||||
|
|
||||||
|
### [2026-08-06 01:30] Task: Note vẽ mới trong piano roll nghe nhạc cụ track TRƯỚC — 2 lỗi preview
|
||||||
|
- **Báo cáo user:** track 4 percussion — click note vẽ từ trước = percussion ✓; VẼ note mới = nhạc cụ track 3.
|
||||||
|
- **2 lỗi:**
|
||||||
|
(1) `_playNoteFallback` (soundfontPlayer dòng 664): `prog = _channels[channel].program || prog` — **override program track bằng cache channel** (track 3 cùng channel ghi đè) → oscillator preview mang character track 3. FIX: chỉ override khi `program === undefined`.
|
||||||
|
(2) **Draw/brush preview dùng `_playNoteFallback` (oscillator beep)** thay vì `playNote` (FluidSynth — nhạc cụ thật) — click note cũ dùng playNote nên đúng. FIX app.jsx: cả 2 chỗ (brush ~7725, draw ~7819) → `playNote` với `resolveTrackInstrumentCtx` (ch/synthEngine của track).
|
||||||
|
- **Các file ảnh hưởng:** `soundfontPlayer.js` + `app.jsx` + `index.html` (?v=202608060130 cho cả 2), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → track percussion → VẼ note mới → KỲ VỌNG: percussion thật (không phải nhạc cụ track trước).
|
||||||
|
|
||||||
|
### [2026-08-06 02:00] Task: Percussion piano-roll play "1 âm đầu rồi câm" — quét preset hợp lệ toàn font (cache theo sfId)
|
||||||
|
- **Báo cáo user:** track 4 percussion — play preview trong piano roll: chỉ 1 âm đầu, các âm sau câm.
|
||||||
|
- **Cơ chế:** fallback v01:00 chỉ thử 4 preset cố định [128,48],[0,0],[128,1],[0,48] — font "latin hand perc" KHÔNG có preset nào → `finalBank/finalProg` giữ (128,0) → channel cache = (128,0) INVALID → noteon thứ 2: progAlreadySet = true (cache khớp (128,0)) → SKIP re-select → noteon trên preset rỗng = CÂM. Âm đầu = noteon trên preset DEFAULT của font (auto-assign khi load).
|
||||||
|
- **FIX (soundfontPlayer doNote):** khi `finalBank === 128` → **quét toàn bộ preset font: bank 128 0-127 + bank 0 0-127 (tối đa 256 program_select, probe qua channel 9)** — chọn preset đầu trả 0 → cache `_validPercCache[sfId]` → finalBank/finalProg = preset hợp lệ → select + noteon trên preset ĐÚNG → mọi note kêu. Cache 1 lần/font (lần sau không quét lại).
|
||||||
|
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060200 — chỉ hard refresh, không build), `wiki.md`.
|
||||||
|
- **Ghi chú/Test:** hard refresh → play track percussion trong piano roll → KỲ VỌNG: MỌI note kêu (không chỉ âm đầu).
|
||||||
|
|
||||||
|
### [2026-08-06 03:00] Task: BẢO ĐẢM mastering chain xử lí MỌI track (solo) + preview piano roll khi mastering ON
|
||||||
|
- **Yêu cầu user:** (1) track solo → luồng âm PHẢI qua mastering chain khi ON (âm to); (2) preview note MIDI trong piano roll PHẢI qua mastering chain khi ON.
|
||||||
|
- **Cơ chế cũ:** ♪ bypass (trackMidiBypassMap/trackAudioBypassMap) → routeGain=0/dryGain=1 → track bỏ qua chain — kể cả khi chain ON.
|
||||||
|
- **FIX (app.jsx):**
|
||||||
|
(1) Helpers: `masteringChainOn()` + `effMidiBypass(track)`/`effAudioBypass(track)` — **chain ON → bypass luôn false (♪ bị override); chain OFF → theo ♪ maps**.
|
||||||
|
(2) Áp tại: createMasteringRoute (audio route), getOrCreateTrackNode sfBypass (~18241), buildOfflineTrackNode (~1031), sync effect (~14538).
|
||||||
|
(3) `[masteringSettings]` effect: sau toggle+apply → **re-sync live nodes** (sfRouteGain/sfDryGain + route.routeGain/dryGain theo effBypass) + updateSfRouting — PWR bật/tắt áp ngay lên node đang phát.
|
||||||
|
(4) Preview piano roll: SF → sfEntry → sfRouteGain (=1 khi chain ON) → masterBus.input → chain ✓.
|
||||||
|
- **⚠️ Sửa hậu quả patch replace_all hỏng 3 vùng** (createMasteringRoute, sync effect, node creation — khôi phục đúng nguyên bản + áp helper đúng chỗ).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060300), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track bất kỳ → âm qua chain (to); preview note trong piano roll → qua chain.
|
||||||
|
|
||||||
|
### [2026-08-06 03:30] Task: Solo track AI không qua mastering khi bỏ solo track 1 — ép route tại thời điểm routing
|
||||||
|
- **Báo cáo user:** solo track 1 + solo track AI → cả 2 qua mastering ✓; bỏ solo track 1 → track AI solo KHÔNG qua mastering ✗.
|
||||||
|
- **Phân tích:** 2 case khác nhau: >1 audible → SF fallback `setOutputDestination(null)` → masterBus.input → chain ✓; 1 audible → SF → node.sfEntry → sfMods → sfRouteGain → chain — nếu sfRouteGain bị 0 (dry — node tạo lúc mastering OFF / state stale) → KHÔNG qua chain. AI track template không có bypass field (sạch) — nên nguyên nhân là ROUTE STALE tại node.
|
||||||
|
- **FIX (app.jsx updateSfRouting):** ép `sfRouteGain/sfDryGain` theo `effMidiBypass` NGAY TẠI thời điểm routing — cả nhánh PIANO_ROLL + nhánh midiAudible single-track (belt-and-suspenders — không chỉ lúc tạo node). Sửa lỗi gọi effMidiBypass với trackId string → track object.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060330), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track 1 + solo track AI → bỏ solo track 1 → track AI phải VẪN qua mastering. Nếu vẫn lỗi → dán console (tìm `[Bypass]` + `setOutputDestination` + `updateSfRouting error`).
|
||||||
|
|
||||||
|
### [2026-08-06 04:00] Task: AI Var clone kế thừa midiChannel track gốc → solo bị nhỏ (CC7 collision)
|
||||||
|
- **Báo cáo user:** track do USER chèn → solo âm bình thường; track do AI prompt chèn (AI Var) → solo âm NHỎ (nút solo con của track gốc). Yêu cầu kiểm tra quá trình AI clone.
|
||||||
|
- **Cơ chế (dòng 23042):** `const newTrack = { ...(srcTrack || {}), ... }` — clone AI Var spread TOÀN BỘ track gốc → **kế thừa `midiChannel`** → clone + track gốc DÙNG CHUNG channel. Solo track gốc (hoặc clone) → sync effect gửi `controllerChange(ch, 7, audible ? 100 : 0)` — track bị solo-mute (cùng channel) nhận CC7=0 → **notes của clone (cùng channel) cũng bị CC7=0 → âm NHỎ/CÂM**.
|
||||||
|
- **FIX (dòng 23046):** thêm `midiChannel: undefined` vào clone — `ensureTrackMidiChannel` (đã có guard) cấp channel RIÊNG (loop 0-15 skip 9). Kiểm tra: chỉ 1 chỗ spread srcTrack (23043) — AI composition dùng template sạch ✓.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060400), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → tạo [AI Var] từ track → solo track AI Var → âm PHẢI bình thường (không nhỏ); solo track gốc → AI Var không bị ảnh hưởng.
|
||||||
|
|
||||||
|
### [2026-08-06 04:30] Task: Tab deactive → stop âm của tab đó (piano-roll tiếp tục kêu khi quay main)
|
||||||
|
- **Báo cáo user:** piano roll tab đang play → quay về MAIN/SECTION-TAB → VẪN nghe âm piano roll. Yêu cầu: tab nào deactive → stop âm tab đó.
|
||||||
|
- **FIX (app.jsx effect [activeTab] ~14571):** `prevActiveTabRef` lưu tab trước; khi đổi tab → nếu tab CŨ là sub-tab (PIANO_ROLL/section/audio) đang `isPlaying` → `stopAllPlayback()` + set `isPlaying: false` cho tab đó. Tab MỚI không bị ảnh hưởng; MAIN giữ hành vi cũ (mở piano-roll lúc main play → main tiếp tục — handleEditMidiInTab đã xử lý).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060430), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → piano-roll play → bấm tab MAIN → âm piano-roll phải DỪNG ngay; play main → mở piano-roll → main tiếp tục (hành vi cũ).
|
||||||
|
|
||||||
|
### [2026-08-06 05:00] Task: Project không mở được khi đăng nhập máy khác/ẩn danh — 2 fix cross-machine
|
||||||
|
- **Báo cáo user:** login máy khác / browser ẩn danh → không mở được project đã tạo trước đó.
|
||||||
|
- **Chẩn đoán:** backend cloud key ĐÚNG theo user_id (JWT) ✓; OpenProjectModal có tab Cloud (list per-user) + Local (localStorage MÁY-ĐỊNH XỨ). Vấn đề: (1) `restoreLastSessionProject` đọc `sonic_project_id` từ localStorage — máy mới → trống → không restore gì; (2) project lưu LOCAL (`local_` id — chưa login lúc save) → localStorage → machine-bound.
|
||||||
|
- **FIX (app.jsx):**
|
||||||
|
(1) `restoreLastSessionProject`: `!lastId` (máy mới) + có profile (currentUser hoặc `localStorage sonic_user`) → **tự mở project Cloud GẦN NHẤT** (listCloudProjects → [0] → getCloudProject → restore). Local id có sẵn → hành vi cũ.
|
||||||
|
(2) `handleSaveLocalProject`: đã login → **đồng bộ lên Cloud (fire-and-forget saveCloudProject)** — project mở được từ máy khác.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060500), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → login máy mới/ẩn danh → tự mở project Cloud gần nhất. Lưu local khi login → xuất hiện trong Cloud tab ở máy khác. Project local CŨ (tạo trước fix) → mở trên máy cũ + lưu lại → sync.
|
||||||
|
|
||||||
|
### [2026-08-06 05:30] Task: Incognito instrument câm — font KHÔNG còn trên server (chỉ trong IndexedDB browser thường)
|
||||||
|
- **Báo cáo user:** load project cũ ở browser ẩn danh → instrument không có âm; browser thường → OK.
|
||||||
|
- **Chẩn đoán:** soundfont (SGM-V2.01, latin hand perc) load từ IndexedDB cache → incognito cache RỖNG → fetch `/api/v1/plugins/soundfonts/download/{sfId}` → **404 — font KHÔNG còn trên server** (upload dir chỉ còn weedsgm3/518e850f; static/soundfonts rỗng; SYSTEM_SF_DIR không tồn tại). Browser thường: IndexedDB đã cache (từ lúc font từng tồn tại server) → không fetch → OK.
|
||||||
|
- **FIX:**
|
||||||
|
(1) Backend `app/api/v1/plugins.py` download endpoint: thêm `static/soundfonts` vào danh sách thư mục tìm (font bundled).
|
||||||
|
(2) Frontend `soundfontPlayer.js` loadSoundFont: fetch API download fail → **fallback `/soundfonts/{sfId}`** (route tĩnh).
|
||||||
|
- **ĐIỀU KIỆN ĐỦ:** font PHẢI tồn tại trên server — user cần đặt file `SGM-V2.01.sf2/.sf3` + `latin hand perc.sf2/.sf3` vào `app/storage/soundfonts/` (hoặc upload qua UI) → mọi máy/browser fetch được.
|
||||||
|
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `soundfontPlayer.js` (?v=202608060530 — hard refresh), `wiki.md`. Backend cần restart.
|
||||||
|
- **Ghi chú/Test:** đặt font vào storage/soundfonts → restart backend → hard refresh → incognito load project → instrument có âm.
|
||||||
|
|
||||||
|
### [2026-08-06 06:00] Task: Incognito log xác nhận font vẫn 404 + fix crash onMouseMove (guard clip.buffer)
|
||||||
|
- **Log incognito (v0530):** `soundfont not loaded yet, loading: SGM-V2.01` ×17 — load thất bại liên tục = font VẪN không có trên server (404). Kèm `Uncaught TypeError: Cannot read properties of undefined (reading 'duration')` onMouseMove — guard clip.buffer bị mất theo commit user 55d3464.
|
||||||
|
- **FIX (app.jsx):** re-apply guard `c.buffer` cho 5 chỗ `.buffer.duration` (rightEdgeClip ×2, hoveredClip, clickedClip ×2) — hết crash khi clip không có buffer (audio file load fail ở incognito).
|
||||||
|
- **ĐIỀU KIỆN CẦN (chưa đủ — user PHẢI thực hiện):** đặt file font (SF2 — export từ cache browser thường hoặc copy từ production /opt/daw_engine/soundfonts) vào `app/storage/soundfonts/` — dev instance KHÔNG có ffmpeg → KHÔNG dùng được .sf3 (cần .sf2). Fix code endpoint + fallback đã vào (v0530) — chỉ có tác dụng khi file tồn tại server-side.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060600), `wiki.md`. Rebuild precompiled.
|
||||||
|
|
||||||
|
### [2026-08-06 06:30] Task: Note bị DROP khi font không tải được (incognito) → fallback oscillator + cooldown 10s
|
||||||
|
- **Xác nhận hypothesis user:** "incognito bỏ qua bước FluidSynth WASM" — đúng cơ chế: doNote `if (finalSfId && !_sfHandleMap.has(finalSfId))` → loadSoundFont → `if (ok) doNote();` — **load FAIL (font 404) → note bị DROP âm thầm → WASM không nhận noteon → CÂM.** Incognito: cache rỗng → fetch 404; browser thường: cache có → ok.
|
||||||
|
- **FIX (soundfontPlayer.js doNote):**
|
||||||
|
(1) Load fail → **`_playNoteFallback` (oscillator — CÓ ÂM thay vì câm lặng)** + `_sfLoadFailAt[sfId]` timestamp.
|
||||||
|
(2) **Cooldown 10s**: sau fail, các note tiếp theo chạy thẳng fallback (không spam fetch 404 mỗi note).
|
||||||
|
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060630 — hard refresh, không build), `wiki.md`.
|
||||||
|
- **Ghi chú/Test:** hard refresh → incognito play track font chưa có → NGHE ĐƯỢC fallback (beep theo pattern — không câm). Vẫn khuyến nghị đặt font thật (SGM-V2.01.sf2...) để có âm thật.
|
||||||
|
|
||||||
|
### [2026-08-06 07:00] Task: Bundle mới KHÔNG load ở incognito — index.html cache heuristic (thiếu Cache-Control)
|
||||||
|
- **Báo cáo user:** re-compile + rebuild docker nhưng incognito vẫn không load bundle mới (URL cũ).
|
||||||
|
- **Xác minh production (daw.labz.io.vn):** index.html ĐÃ serve `soundfontPlayer?v=202608060630` + `precompiled?v=202608060600` — bundle MỚI NHẤT (precompiled chứa 9 markers fix: nanOut/effMidiBypass/prevActiveTabRef). **Production ĐÚNG** — vấn đề: **incognito dùng index.html CACHED CŨ** (stamp cũ → URL bundle cũ). Server không gửi Cache-Control → browser cache heuristic → HTML cũ.
|
||||||
|
- **FIX (app/main.py):** index.html (`/`) thêm `Cache-Control: no-cache, no-store, must-revalidate` — HTML luôn mới, bundle JS bust bằng ?v=.
|
||||||
|
- **Các file ảnh hưởng:** `app/main.py`. Cần rebuild docker + restart.
|
||||||
|
- **Ghi chú/Test:** sau khi deploy: incognito (đóng + mở lại tab — hoặc Ctrl+Shift+R 1 lần) → load trang → bundle mới. Verify: console thấy stamp mới.
|
||||||
|
|
||||||
|
### [2026-08-06 07:30] Task: PIANO ROLL TAB — Humanize + Transpose (có undo)
|
||||||
|
- **Yêu cầu user:** cài đặt tính năng Humanize (midi note) + Transpose (chuyển giọng) trong piano roll tab.
|
||||||
|
- **FIX (app.jsx PianoRollTabEditor):**
|
||||||
|
(1) `applyHumanize()` — random velocity ±8% + start_beat ±0.015 beat (~12ms @120bpm), clamp 0.05-1.0/≥0 — pushToUndo trước khi đổi.
|
||||||
|
(2) `applyTranspose(semi)` — dịch pitch ±s semitone, clamp 0-127 — pushToUndo trước khi đổi.
|
||||||
|
(3) Toolbar: nút **🎚 Humanize** (sau nút Ghost) + nhóm **input semitone + nút Transpose** (trước nút Lưu). State `transposeSemis` local.
|
||||||
|
(4) Cả 2 đều qua `pushToUndo(notes)` → Ctrl+Z/Ctrl+Shift+Z hoạt động (undo stack local của piano roll).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060730), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → bấm Humanize (nghe velocity/timing đổi) → Transpose +2 (nghe cao hơn 1 tone) → Ctrl+Z hoàn tác.
|
||||||
|
|
||||||
|
### [2026-08-06 08:00] Task: Humanize có cường độ (Nhẹ/Vừa/Mạnh) + Transpose theo SCALE (12 tông major/minor)
|
||||||
|
- **Yêu cầu user:** tùy chỉnh lượng humanize (mạnh/nhẹ) + transpose theo scale major/minor đủ 12 tông.
|
||||||
|
- **FIX (app.jsx PianoRollTabEditor):**
|
||||||
|
(1) Humanize: `humanizeStrength` state (0.05 Nhẹ / 0.10 Vừa / 0.18 Mạnh) + select trong toolbar — velocity ±strength, timing ±strength*0.15 beat.
|
||||||
|
(2) Transpose theo SCALE: `SCALE_PATTERNS` (major [0,2,4,5,7,9,11], minor [0,2,3,5,7,8,10]) + `SCALE_ROOTS` (12 tông) + **`detectKey()` auto-detect key nguồn** (best-fit root+scale theo pitch class) + `applyTransposeToKey()` map **degree → degree** (nốt về bậc gần nhất trong scale nguồn → shift sang bậc tương ứng scale đích, ±6 clamp octave).
|
||||||
|
(3) Toolbar: `[🎚 Humanize] [Nhẹ|Vừa|Mạnh] [semis|Transpose] [C..B][major|minor][🎵 Chuyển giọng]` — đều qua pushToUndo (Ctrl+Z hoạt động).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060800), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → piano roll → Humanize Mạnh vs Nhẹ; Chuyển giọng C major → D minor (map degree — melody giữ hình dạng) → Ctrl+Z.
|
||||||
|
|
||||||
|
### [2026-08-06 08:30] Task: Toolbar piano roll 2 hàng — nhóm nút chỉnh sửa note sang hàng mới
|
||||||
|
- **Yêu cầu user:** thêm hàng toolbar mới, di chuyển nút tính năng tương tự sang hàng mới.
|
||||||
|
- **FIX (app.jsx):** header toolbar đổi `h-10 flex items-center justify-between` → `flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5` + **row-break spacer** (`flexBasis:100%, height:0`) trước nút Humanize.
|
||||||
|
- **Hàng 1:** track select, Snap to Scale, Snap, ARM, MIDI Input, Instrument, AI bar range, CC mode/CC, Session/Isolated, Ghost.
|
||||||
|
- **Hàng 2:** 🎚 Humanize + [Nhẹ|Vừa|Mạnh], ±semis Transpose, [C..B][major|minor] 🎵 Chuyển giọng, Lưu, Export, Đóng.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060830), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → thấy 2 hàng toolbar; nút edit note (Humanize/Transpose/Chuyển giọng) ở hàng 2.
|
||||||
|
|
||||||
|
### [2026-08-06 09:30] Task: Fix emoji double-escape — nút "MIDI ghost notes" hiện text \uD83D\uDC7B
|
||||||
|
- **Báo cáo user:** nút MIDI ghost notes hiện ký tự lạ "\uD83D\uDC7B" (text literal thay vì 👻).
|
||||||
|
- **Nguyên nhân:** file app.jsx có `"\\uD83D\\uDC7B MIDI ghost notes"` (escape KÉP → runtime render text literal).
|
||||||
|
- **FIX:** thay bằng emoji thật `"👻 MIDI ghost notes"`. Kiểm tra: các emoji khác (Humanize 🎚, Chuyển giọng 🎵, Session 🌐/Isolated 📋, Nhẹ/Vừa/Mạnh) đều single-escape ✓ — chỉ nút Ghost bị lỗi.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060930), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → nút hiện "👻 MIDI ghost notes".
|
||||||
|
|
||||||
|
### [2026-08-06 10:00] Task: Auto-detect scale khi mở midi item → hiển thị ở dropdown chuyển giọng
|
||||||
|
- **Yêu cầu user:** mở midi item trong piano roll → detect scale + hiển thị ở dropdown scale chuyển giọng.
|
||||||
|
- **FIX (app.jsx):** effect trong PianoRollTabEditor — key `[st.target_id]` (item id): khi mở item → `detectKey(st.notes)` → `setKeyTargetRoot/KeyTargetScale` = giọng detected → dropdown chuyển giọng hiển thị đúng giọng của item. Sửa note cùng item KHÔNG reset (target_id không đổi); mở item khác → detect lại.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061000), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → mở midi item → dropdown hiện giọng detected (vd D minor) → bấm Chuyển giọng sang giọng khác → Ctrl+Z.
|
||||||
|
|
||||||
|
### [2026-08-06 10:30] Task: Toolbar — Đóng sang cuối hàng TRÊN (phải), Lưu + Export MIDI cuối hàng DƯỚI (phải)
|
||||||
|
- **Yêu cầu user:** move Lưu/Export (đổi tên → Export MIDI) cuối hàng bên phải; Đóng → cuối hàng trên bên phải.
|
||||||
|
- **FIX (app.jsx):** nút Đóng chuyển từ cuối toolbar → SAU nút MIDI ghost notes (hàng 1) + `ml-auto` (đẩy phải); nhóm Lưu/Export giữ cuối hàng 2 + `ml-auto`; Export → **Export MIDI**. Emoji 🎵 Chuyển giọng bị patch tool double-escape → sửa bằng emoji thật (verify: 0 double-escape còn lại).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061030), `wiki.md`. Rebuild precompiled.
|
||||||
|
- **Ghi chú/Test:** `npm run build` → hard refresh → hàng 1 phải có [Đóng]; hàng 2 phải: [Lưu] [Export MIDI] ở cuối bên phải.
|
||||||
|
|||||||
Reference in New Issue
Block a user