FIX: lỗi mở MIDI item ở trong PIANO ROLL TAB là bị lỗi âm thanh của main session

This commit is contained in:
2026-08-04 22:07:48 +07:00
parent 0272912cff
commit 856a8183b6
5 changed files with 521 additions and 136 deletions
+247 -85
View File
@@ -42,6 +42,48 @@ const assignTrackMidiChannel = (track, tracks) => {
return ch; return ch;
}; };
// Instrument context (PIANO ROLL play ngun duy nht, mi nơi dùng)
// Track "đã loaded instrument" = track.instrumentProgram (GM preset) HOC
// track.synth_engine (soundfont: soundfont_id/bank/program). Resolve thành
// context thng nht cho schedulePianoRollMidi + preview (wheel/click/keybed)
// đ note PHI chơi đúng instrument ca track không ph thuc st snapshot.
const resolveTrackInstrumentCtx = (track, tracks) => {
const all = tracks || [];
const ch = track ? assignTrackMidiChannel(track, all) : 0;
const se = track ? track.synth_engine : undefined;
const isSf = !!(se && (se.type === 'soundfont' || se.soundfont_id));
if (isSf) {
return {
ch,
program: undefined, // SF path synthEngine đưc ưu tiên trong _playNoteFluid
synthEngine: se,
sfId: se.soundfont_id,
bank: se.soundfont_bank || 0,
prog: se.soundfont_program || 0
};
}
if (track && track.instrumentProgram !== undefined) {
return { ch, program: track.instrumentProgram, synthEngine: undefined, sfId: undefined, bank: 0, prog: track.instrumentProgram };
}
return { ch, program: undefined, synthEngine: undefined, sfId: undefined, bank: 0, prog: 0 };
};
// Đm bo FluidSynth channel ca track đã select ĐÚNG instrument trưc khi
// notes bn. Fire-and-forget: playNote t load + retry nếu SF chưa load xong
// (dedup sn trong loadSoundFont) không chn, không gây stall khi m tab.
const ensureSonicInstrument = (ctx) => {
try {
if (!window.SonicSF || !window.SonicSF.selectInstrument) return;
if (ctx.sfId) {
window.SonicSF.selectInstrument(ctx.ch, ctx.bank, ctx.prog, ctx.sfId);
} else if (ctx.program !== undefined) {
window.SonicSF.selectInstrument(ctx.ch, 0, ctx.program, null);
}
} catch (e) {
console.warn('[Instrument] ensureSonicInstrument error:', e);
}
};
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project) // Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() { (function handleSfsDeepLink() {
@@ -124,7 +166,8 @@ function computeTrackAudibleGain(trackList, track) {
if (track.muted) return 0; if (track.muted) return 0;
const hasSolo = (trackList || []).some(t => t.solo); const hasSolo = (trackList || []).some(t => t.solo);
if (hasSolo && !track.solo) return 0; if (hasSolo && !track.solo) return 0;
const volDb = track.volumeDb ?? 0; const volDb = Number(track.volumeDb);
if (isNaN(volDb) || !isFinite(volDb)) return 1.0;
return volDb <= -50 ? 0 : Math.pow(10, volDb / 20); return volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
} }
@@ -132,8 +175,9 @@ function computeTrackAudibleGain(trackList, track) {
function setTrackNodeGain(node, gainLinear) { function setTrackNodeGain(node, gainLinear) {
if (!node || !node.gainNode || !audioCtx) return; if (!node || !node.gainNode || !audioCtx) return;
const t = audioCtx.currentTime; const t = audioCtx.currentTime;
const g = (typeof gainLinear === 'number' && isFinite(gainLinear) && !isNaN(gainLinear)) ? gainLinear : 1.0;
node.gainNode.gain.cancelScheduledValues(t); node.gainNode.gain.cancelScheduledValues(t);
node.gainNode.gain.setTargetAtTime(gainLinear, t, 0.02); node.gainNode.gain.setTargetAtTime(g, t, 0.02);
} }
// MAIN SESSION end-time (seconds): endtime of the items ON the session's own // MAIN SESSION end-time (seconds): endtime of the items ON the session's own
@@ -218,6 +262,7 @@ function makeDistortionCurve(k) {
} }
function applyMasteringSettings(s) { function applyMasteringSettings(s) {
console.log('[Mastering] applyMasteringSettings active=', !!(s && s.masterConnected), 'bypass=', !!(s && s.isBypassed), 'eqActive=', !!(s && s.eqActive), 'masterBus=', !!masterBus);
if (!masterBus || !audioCtx || !s) return; if (!masterBus || !audioCtx || !s) return;
// getAudioContext() invokes this on EVERY call (stopAll, play, VU, ). // getAudioContext() invokes this on EVERY call (stopAll, play, VU, ).
// Re-applying identical values in rapid bursts is "fast parameter automation" // Re-applying identical values in rapid bursts is "fast parameter automation"
@@ -241,14 +286,17 @@ function applyMasteringSettings(s) {
// cancelScheduledValues + a slower time constant keeps rapid slider drags // cancelScheduledValues + a slower time constant keeps rapid slider drags
// from piling up automation events on the biquad filters (the trigger for // from piling up automation events on the biquad filters (the trigger for
// Chromium's "BiquadFilterNode: state is bad"). // Chromium's "BiquadFilterNode: state is bad").
// Guard NaN: setValueAtTime(NaN) trên biquad Chromium "BiquadFilterNode:
// state is bad" + câm. Field settings undefined/NaN mc đnh 0.
const _g = (v, lo, hi) => (typeof v === 'number' && isFinite(v)) ? clamp(v, lo, hi) : 0;
masterBus.eqLowFilter.gain.cancelScheduledValues(now); masterBus.eqLowFilter.gain.cancelScheduledValues(now);
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqLowGain, -24, 24) : 0, now, 0.05); masterBus.eqLowFilter.gain.setValueAtTime(s.eqActive ? _g(s.eqLowGain, -24, 24) : 0, now);
masterBus.eqMid1Filter.gain.cancelScheduledValues(now); masterBus.eqMid1Filter.gain.cancelScheduledValues(now);
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid1Gain, -24, 24) : 0, now, 0.05); masterBus.eqMid1Filter.gain.setValueAtTime(s.eqActive ? _g(s.eqMid1Gain, -24, 24) : 0, now);
masterBus.eqMid2Filter.gain.cancelScheduledValues(now); masterBus.eqMid2Filter.gain.cancelScheduledValues(now);
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid2Gain, -24, 24) : 0, now, 0.05); masterBus.eqMid2Filter.gain.setValueAtTime(s.eqActive ? _g(s.eqMid2Gain, -24, 24) : 0, now);
masterBus.eqHighFilter.gain.cancelScheduledValues(now); masterBus.eqHighFilter.gain.cancelScheduledValues(now);
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqHighGain, -24, 24) : 0, now, 0.05); masterBus.eqHighFilter.gain.setValueAtTime(s.eqActive ? _g(s.eqHighGain, -24, 24) : 0, now);
// 2. Imager Settings (Mid/Side width per band imager_spec.md) // 2. Imager Settings (Mid/Side width per band imager_spec.md)
// Width % semantics per the guide: 0% = MONO (S × 0), 100% = original // Width % semantics per the guide: 0% = MONO (S × 0), 100% = original
@@ -675,7 +723,7 @@ const EQPRO_DEFAULT_BANDS = [
{ type: 'highshelf', freq: 10000, gain: 0, q: 0.7, active: true } { type: 'highshelf', freq: 10000, gain: 0, q: 0.7, active: true }
]; ];
function eqproFreqToX(f, w) { return w * (Math.log10(f / EQPRO_F_MIN) / Math.log10(EQPRO_F_MAX / EQPRO_F_MIN)); } function eqproFreqToX(f, w) { return w * (Math.log10(f / EQPRO_F_MIN) / Math.log10(EQPRO_F_MAX / EQPRO_F_MIN)); }
function eqproClamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } function eqproClamp(v, lo, hi) { return (typeof v === 'number' && isFinite(v)) ? Math.min(hi, Math.max(lo, v)) : lo; }
function eqproXToFreq(x, w) { return EQPRO_F_MIN * Math.pow(EQPRO_F_MAX / EQPRO_F_MIN, eqproClamp(x, 0, w) / w); } function eqproXToFreq(x, w) { return EQPRO_F_MIN * Math.pow(EQPRO_F_MAX / EQPRO_F_MIN, eqproClamp(x, 0, w) / w); }
function eqproGainToY(g, h) { return (h / 2) - (g * ((h / 2) / EQPRO_MAX_DB)); } function eqproGainToY(g, h) { return (h / 2) - (g * ((h / 2) / EQPRO_MAX_DB)); }
function eqproYToGain(y, h) { return ((h / 2) - y) * (EQPRO_MAX_DB / (h / 2)); } function eqproYToGain(y, h) { return ((h / 2) - y) * (EQPRO_MAX_DB / (h / 2)); }
@@ -750,10 +798,11 @@ function createEqProModule(ctx, params) {
filters.forEach(f => { try { f.disconnect(); } catch (e) { } }); filters.forEach(f => { try { f.disconnect(); } catch (e) { } });
filters.length = 0; filters.length = 0;
let tail = input; let tail = input;
const maxF = (ctx.sampleRate || 44100) * 0.45;
bands.forEach(b => { bands.forEach(b => {
const f = ctx.createBiquadFilter(); const f = ctx.createBiquadFilter();
f.type = b.type || 'peaking'; f.type = b.type || 'peaking';
f.frequency.value = eqproClamp(b.freq !== undefined ? b.freq : 1000, EQPRO_F_MIN, EQPRO_F_MAX); f.frequency.value = eqproClamp(b.freq !== undefined ? b.freq : 1000, EQPRO_F_MIN, Math.min(EQPRO_F_MAX, maxF));
f.Q.value = eqproClamp(b.q !== undefined ? b.q : 1, 0.1, 18); f.Q.value = eqproClamp(b.q !== undefined ? b.q : 1, 0.1, 18);
f.gain.value = (b.active !== false) ? ((b.gain || 0) * amount / 100) : 0; f.gain.value = (b.active !== false) ? ((b.gain || 0) * amount / 100) : 0;
tail.connect(f); tail.connect(f);
@@ -768,8 +817,9 @@ function createEqProModule(ctx, params) {
Object.assign(b, patch); Object.assign(b, patch);
const f = filters[i]; if (!f) return; const f = filters[i]; if (!f) return;
const now = ctx.currentTime; const now = ctx.currentTime;
const maxF = (ctx.sampleRate || 44100) * 0.45;
if (patch.type !== undefined) f.type = patch.type; if (patch.type !== undefined) f.type = patch.type;
if (patch.freq !== undefined) f.frequency.setValueAtTime(eqproClamp(b.freq, EQPRO_F_MIN, EQPRO_F_MAX), now); if (patch.freq !== undefined) f.frequency.setValueAtTime(eqproClamp(b.freq, EQPRO_F_MIN, Math.min(EQPRO_F_MAX, maxF)), now);
if (patch.q !== undefined) f.Q.setValueAtTime(eqproClamp(b.q, 0.1, 18), now); if (patch.q !== undefined) f.Q.setValueAtTime(eqproClamp(b.q, 0.1, 18), now);
if (patch.gain !== undefined || patch.active !== undefined) f.gain.setValueAtTime((b.active !== false) ? ((b.gain || 0) * amount / 100) : 0, now); if (patch.gain !== undefined || patch.active !== undefined) f.gain.setValueAtTime((b.active !== false) ? ((b.gain || 0) * amount / 100) : 0, now);
}; };
@@ -797,32 +847,39 @@ function createEqProModule(ctx, params) {
} }
function createTrackFxModule(type, ctx, params) { function createTrackFxModule(type, ctx, params) {
const num = (v, def) => {
const n = Number(v);
return isFinite(n) && !isNaN(n) ? n : def;
};
const maxF = (ctx.sampleRate || 44100) * 0.45;
const clampF = v => Math.max(20, Math.min(v, maxF));
const input = ctx.createGain(); const input = ctx.createGain();
const output = ctx.createGain(); const output = ctx.createGain();
const p = params || {}; const p = params || {};
let nodes = {}; let nodes = {};
if (type === 'compressor') { if (type === 'compressor') {
const comp = ctx.createDynamicsCompressor(); const comp = ctx.createDynamicsCompressor();
comp.threshold.value = p.threshold !== undefined ? p.threshold : -16; comp.threshold.value = num(p.threshold, -16);
comp.knee.value = 8; comp.ratio.value = p.ratio !== undefined ? p.ratio : 3; comp.knee.value = 8; comp.ratio.value = num(p.ratio, 3);
comp.attack.value = 0.02; comp.release.value = 0.25; comp.attack.value = 0.02; comp.release.value = 0.25;
const makeup = ctx.createGain(); makeup.gain.value = p.makeup !== undefined ? Math.pow(10, p.makeup / 20) : 1.0; const makeup = ctx.createGain(); makeup.gain.value = Math.pow(10, num(p.makeup, 0) / 20);
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(); const lim = ctx.createDynamicsCompressor();
lim.threshold.value = p.ceiling !== undefined ? p.ceiling : -1.0; lim.threshold.value = num(p.ceiling, -1.0);
lim.knee.value = 0; lim.ratio.value = 20; lim.knee.value = 0; lim.ratio.value = 20;
lim.attack.value = 0.001; lim.release.value = 0.05; lim.attack.value = 0.001; lim.release.value = 0.05;
input.connect(lim); lim.connect(output); input.connect(lim); lim.connect(output);
nodes = { lim }; nodes = { lim };
} else if (type === 'exciter') { } else if (type === 'exciter') {
const hp = ctx.createBiquadFilter(); const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 2000; hp.Q.value = 0.7; hp.type = 'highpass'; hp.frequency.value = clampF(2000); hp.Q.value = 0.7;
const shaper = ctx.createWaveShaper(); const shaper = ctx.createWaveShaper();
shaper.curve = makeDistortionCurve(3); shaper.oversample = '4x'; shaper.curve = makeDistortionCurve(3); shaper.oversample = '4x';
const dry = ctx.createGain(); dry.gain.value = 1.0; const dry = ctx.createGain(); dry.gain.value = 1.0;
const wet = ctx.createGain(); wet.gain.value = ((p.drive !== undefined ? p.drive : 40) / 100) * 0.6; const wet = ctx.createGain(); wet.gain.value = (num(p.drive, 40) / 100) * 0.6;
input.connect(dry); dry.connect(output); input.connect(dry); dry.connect(output);
input.connect(hp); hp.connect(shaper); shaper.connect(wet); wet.connect(output); input.connect(hp); hp.connect(shaper); shaper.connect(wet); wet.connect(output);
nodes = { hp, shaper, dry, wet }; nodes = { hp, shaper, dry, wet };
@@ -830,8 +887,8 @@ function createTrackFxModule(type, ctx, params) {
const split = ctx.createChannelSplitter(2); const split = ctx.createChannelSplitter(2);
const merge = ctx.createChannelMerger(2); const merge = ctx.createChannelMerger(2);
const gLL = ctx.createGain(), gRL = ctx.createGain(), gLR = ctx.createGain(), gRR = ctx.createGain(); const gLL = ctx.createGain(), gRL = ctx.createGain(), gLR = ctx.createGain(), gRR = ctx.createGain();
const midLin = Math.pow(10, (p.mid !== undefined ? p.mid : 0) / 20); const midLin = Math.pow(10, num(p.mid, 0) / 20);
const sideLin = Math.pow(10, (p.side !== undefined ? p.side : 0) / 20); const sideLin = Math.pow(10, num(p.side, 0) / 20);
const a = (midLin + sideLin) / 2, b = (midLin - sideLin) / 2; const a = (midLin + sideLin) / 2, b = (midLin - sideLin) / 2;
gLL.gain.value = a; gRR.gain.value = a; gRL.gain.value = b; gLR.gain.value = b; gLL.gain.value = a; gRR.gain.value = a; gRL.gain.value = b; gLR.gain.value = b;
input.connect(split); input.connect(split);
@@ -845,14 +902,14 @@ function createTrackFxModule(type, ctx, params) {
return createEqProModule(ctx, params); return createEqProModule(ctx, params);
} else { } else {
// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB) // 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = 100; const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = clampF(100);
const f2 = ctx.createBiquadFilter(); f2.type = 'peaking'; f2.frequency.value = 800; f2.Q.value = 0.7; const f2 = ctx.createBiquadFilter(); f2.type = 'peaking'; f2.frequency.value = clampF(800); f2.Q.value = 0.7;
const f3 = ctx.createBiquadFilter(); f3.type = 'peaking'; f3.frequency.value = 3200; f3.Q.value = 1.2; const f3 = ctx.createBiquadFilter(); f3.type = 'peaking'; f3.frequency.value = clampF(3200); f3.Q.value = 1.2;
const f4 = ctx.createBiquadFilter(); f4.type = 'highshelf'; f4.frequency.value = 10000; const f4 = ctx.createBiquadFilter(); f4.type = 'highshelf'; f4.frequency.value = clampF(10000);
f1.gain.value = p.g1 !== undefined ? p.g1 : 0; f1.gain.value = num(p.g1, 0);
f2.gain.value = p.g2 !== undefined ? p.g2 : 0; f2.gain.value = num(p.g2, 0);
f3.gain.value = p.g3 !== undefined ? p.g3 : 0; f3.gain.value = num(p.g3, 0);
f4.gain.value = p.g4 !== undefined ? p.g4 : 0; f4.gain.value = num(p.g4, 0);
input.connect(f1); f1.connect(f2); f2.connect(f3); f3.connect(f4); f4.connect(output); input.connect(f1); f1.connect(f2); f2.connect(f3); f3.connect(f4); f4.connect(output);
nodes = { f1, f2, f3, f4 }; nodes = { f1, f2, f3, f4 };
} }
@@ -7067,9 +7124,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
currentBeat < n.start_beat && newBeat >= n.start_beat currentBeat < n.start_beat && newBeat >= n.start_beat
); );
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0; var pvCtx = resolveTrackInstrumentCtx(pvTrk, activeTracks);
ensureSonicInstrument(pvCtx);
playing.forEach(n => { playing.forEach(n => {
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined); window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, pvCtx.program, null, pvCtx.ch, pvCtx.synthEngine);
}); });
} }
} }
@@ -7401,8 +7459,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF) { if (window.SonicSF) {
const ctx = getAudioContext(); const ctx = getAudioContext();
var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var clCh = clTrk ? assignTrackMidiChannel(clTrk, activeTracks) : 0; var clCtx = resolveTrackInstrumentCtx(clTrk, activeTracks);
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null, clCh, clTrk ? clTrk.synth_engine : undefined); ensureSonicInstrument(clCtx);
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, clCtx.program, null, clCtx.ch, clCtx.synthEngine);
} }
} }
@@ -7721,9 +7780,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF && window.SonicSF._playNoteFallback) { if (window.SonicSF && window.SonicSF._playNoteFallback) {
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 pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0; 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, pvTrk ? pvTrk.instrumentProgram : undefined, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined); var pvNodes = window.SonicSF._playNoteFallback(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine);
if (pvNodes) previewNodesRef.current = pvNodes; if (pvNodes) previewNodesRef.current = pvNodes;
previewPitchRef.current = p; previewPitchRef.current = p;
} }
@@ -8038,8 +8097,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const renderKeybed = () => { const renderKeybed = () => {
var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var kbCh = kbTrk ? assignTrackMidiChannel(kbTrk, activeTracks) : 0; var kbCtx = resolveTrackInstrumentCtx(kbTrk, activeTracks);
var kbSynth = kbTrk ? kbTrk.synth_engine : undefined; ensureSonicInstrument(kbCtx);
const keys = []; const keys = [];
for (let pitch = 127; pitch >= PITCH_START; pitch--) { for (let pitch = 127; pitch >= PITCH_START; pitch--) {
const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12);
@@ -8061,7 +8120,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
window.triggerMidiVuActivity(st.trackId, 100); window.triggerMidiVuActivity(st.trackId, 100);
} }
if (window.SonicSF) { if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 500, undefined, st.instrumentProgram, null, kbCh, kbSynth); window.SonicSF.playNote(pitch, 100, 500, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
} }
} catch (err) { } catch (err) {
console.error('playNote error:', err); console.error('playNote error:', err);
@@ -8074,7 +8133,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
window.triggerMidiVuActivity(st.trackId, 100); window.triggerMidiVuActivity(st.trackId, 100);
} }
if (window.SonicSF) { if (window.SonicSF) {
window.SonicSF.playNote(pitch, 100, 200, undefined, st.instrumentProgram, null, kbCh, kbSynth); window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine);
} }
} catch (err) { console.error('playNote error:', err); } } catch (err) { console.error('playNote error:', err); }
} }
@@ -8924,6 +8983,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
duration: st.duration || 4, duration: st.duration || 4,
instrument_program: st.instrumentProgram, instrument_program: st.instrumentProgram,
instrument_name: st.instrumentName, instrument_name: st.instrumentName,
synth_engine: st.synth_engine || null,
current_time: st.currentTime || 0, current_time: st.currentTime || 0,
color: st.color || null color: st.color || null
}; };
@@ -8995,6 +9055,7 @@ const deserializeProjectFromSchema = (schemaObj) => {
duration: st.duration || 4, duration: st.duration || 4,
instrumentProgram: st.instrument_program, instrumentProgram: st.instrument_program,
instrumentName: st.instrument_name, instrumentName: st.instrument_name,
synth_engine: st.synth_engine || null,
currentTime: st.current_time || 0, currentTime: st.current_time || 0,
color: st.color || null color: st.color || null
}; };
@@ -10625,9 +10686,10 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const f = filters[i]; const f = filters[i];
if (!f) return; if (!f) return;
try { try {
f.frequency.setTargetAtTime(bd.freq, now, 0.02); // setValueAtTime (không automation) tránh "BiquadFilterNode: state is bad"
f.gain.setTargetAtTime(bd.gain, now, 0.02); f.frequency.setValueAtTime(bd.freq, now);
f.Q.setTargetAtTime(bd.q, now, 0.02); f.gain.setValueAtTime(bd.gain, now);
f.Q.setValueAtTime(bd.q, now);
} catch (e) {} } catch (e) {}
}); });
} }
@@ -14425,7 +14487,10 @@ const App = () => {
const audibleGain = computeTrackAudibleGain(list, t); const audibleGain = computeTrackAudibleGain(list, t);
if (node) setTrackNodeGain(node, audibleGain); if (node) setTrackNodeGain(node, audibleGain);
if (node && node.sfEntry) { if (node && node.sfEntry) {
try { node.sfEntry.gain.setTargetAtTime(audibleGain, getAudioContext().currentTime, 0.02); } catch (e) {} try {
const g = (typeof audibleGain === 'number' && isFinite(audibleGain) && !isNaN(audibleGain)) ? audibleGain : 1.0;
node.sfEntry.gain.setTargetAtTime(g, getAudioContext().currentTime, 0.02);
} catch (e) {}
} }
// MIDI tracks: mirror the gain decision onto the channel CC7 volume so // MIDI tracks: mirror the gain decision onto the channel CC7 volume so
// FluidSynth-rendered notes respect mute/solo too. // FluidSynth-rendered notes respect mute/solo too.
@@ -14442,6 +14507,10 @@ const App = () => {
updateSfRouting(); updateSfRouting();
}, [tracks, sessionTabs]); }, [tracks, sessionTabs]);
useEffect(() => {
updateSfRouting();
}, [activeTab]);
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
const midiVuActivityRef = useRef({}); const midiVuActivityRef = useRef({});
@@ -15910,17 +15979,29 @@ const App = () => {
const midiItem = (track.midiItems || []).find(m => m.id === midiItemId); const midiItem = (track.midiItems || []).find(m => m.id === midiItemId);
if (!midiItem) return; if (!midiItem) return;
// Clean stop any ongoing playback to avoid stuck playheads and audio routes
stopAllPlayback();
const ctx = getAudioContext();
const silentBuffer = ctx.createBuffer(1, 128, ctx.sampleRate);
const existing = subTabs.find(s => s.type === 'PIANO_ROLL' && s.target_id === midiItemId); const existing = subTabs.find(s => s.type === 'PIANO_ROLL' && s.target_id === midiItemId);
if (existing) { if (existing) {
setSubTabs(prev => prev.map(s => s.id === existing.id ? {
...s,
buffer: s.buffer || silentBuffer,
instrumentProgram: track.instrumentProgram !== undefined ? track.instrumentProgram : (track.synth_engine ? track.synth_engine.soundfont_program : undefined),
instrumentName: track.instrumentName,
instrumentId: track.instrumentId,
synth_engine: track.synth_engine,
} : s));
setActiveTab(existing.id); setActiveTab(existing.id);
showToast('Piano Roll cho nốt MIDI đã được mở.', 'info'); showToast('Piano Roll cho nốt MIDI đã được mở.', 'info');
return; return;
} }
const tabId = 'midi_' + Date.now(); const tabId = 'midi_' + Date.now();
const tabLabel = `Piano Roll: ${midiItem.name || 'MIDI'}`; const tabLabel = `Piano Roll: ${midiItem.name || 'MIDI'}`;
const ctx = getAudioContext();
const silentBuffer = ctx.createBuffer(1, 128, ctx.sampleRate);
const newTab = { const newTab = {
id: tabId, id: tabId,
label: tabLabel, label: tabLabel,
@@ -15948,17 +16029,9 @@ const App = () => {
}; };
setSubTabs(prev => [...prev, newTab]); setSubTabs(prev => [...prev, newTab]);
setActiveTab(tabId); setActiveTab(tabId);
if (track.synth_engine && window.SonicSF && window.SonicSF.selectInstrument) { // KHÔNG loadSoundFont đây (k c nn): sfload SF mi bt đng b làm
var se = track.synth_engine; // WASM heap 256MB đy FluidSynth stall CÂM TOÀN CC (mi âm thanh
var sfId = se.soundfont_id; // chết sau khi m tab). playNote T load + retry đúng lúc note cn.
if (sfId) {
var allTrks = activeTracksRef.current || activeTracks;
// Use the track's dedicated channel (same as every playback path) so the
// preload matches where piano-roll notes will actually sound.
var seCh = assignTrackMidiChannel(track, allTrks);
window.SonicSF.selectInstrument(seCh, se.soundfont_bank || 0, se.soundfont_program || 0, sfId);
}
}
}; };
const handleUpdateMidiNotes = (tabId, notes) => { const handleUpdateMidiNotes = (tabId, notes) => {
@@ -17553,11 +17626,15 @@ const App = () => {
fadeGainNode.connect(route.dryGain); fadeGainNode.connect(route.dryGain);
source.start(context.currentTime, offsetBuffer); source.start(context.currentTime, offsetBuffer);
activeSourcesRef.current = [source]; activeSourcesRef.current = [source];
activeTrackNodesRef.current[st.trackId] = { // PIANO_ROLL: GI track node tht trong ref (SF notes đã schedule ti nó
gainNode: volumeGainNode, // ghi đè bng silent node làm mt mastering route/updateSfRouting đúng).
pannerNode, if (st.type !== 'PIANO_ROLL') {
source activeTrackNodesRef.current[st.trackId] = {
}; gainNode: volumeGainNode,
pannerNode,
source
};
}
// Realtime mute/solo for the newly created playback chain. // Realtime mute/solo for the newly created playback chain.
if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(st.trackId, null); if (window.__applyTrackMuteSolo) window.__applyTrackMuteSolo(st.trackId, null);
startOffsetTimeRef.current = offsetWallTime; startOffsetTimeRef.current = offsetWallTime;
@@ -17649,12 +17726,32 @@ const App = () => {
// Master-silence watchdog: đang play + có source ĐANG TRONG KHONG PHÁT // Master-silence watchdog: đang play + có source ĐANG TRONG KHONG PHÁT
// (theo lch) nhưng master output im lng liên tc ~750ms graph b hng // (theo lch) nhưng master output im lng liên tc ~750ms graph b hng
// (BiquadFilter "state is bad" node cache dính) rebuild nodes. // (BiquadFilter "state is bad" node cache dính) rebuild nodes.
// Đon im lng T NHIÊN (intro/rest mi source nm ngoài khong phát) // Áp cho main + sub-tab AUDIO. PIANO_ROLL tab KHÔNG có ngun liên tc
// KHÔNG trigger rebuild (cooldown 3s phòng trigger lp). // (ch notes MIDI schedule ri rc) rest/im >750ms là T NHIÊN, watchdog
if (isPlaying && activeTabRef.current === 'main' && masterBus && masterBus.analyser && activeSourcesRef.current.length > 0) { // s rebuild loop + panic hy notes ch CÂM TOÀN CC exempt hoàn toàn
// (các fix setValueAtTime/NaN guard đã hết "state is bad" watchdog ch
// còn là lp cu cui cho main/audio-tab).
const _anySubPlaying = subTabsRef.current.some(s => s.isPlaying);
const activeSub = subTabsRef.current.find(s => s.id === activeTabRef.current);
const isPianoRoll = activeSub && activeSub.type === 'PIANO_ROLL';
if (!isPianoRoll && (isPlaying || _anySubPlaying) && masterBus && masterBus.analyser && activeSourcesRef.current.length > 0) {
try { try {
// "Đáng l đang có âm" quyết đnh watchdog có đưc rebuild không:
// - Main / sub-tab audio: source tht đang trong khong phát.
const ctxNow = getAudioContext().currentTime; const ctxNow = getAudioContext().currentTime;
const anyPlaying = activeSourcesRef.current.some(s => typeof s.startTime === 'number' && ctxNow >= s.startTime && ctxNow <= s.startTime + (s.buffer ? s.buffer.duration : 0) + 0.1); let anyPlaying = false;
if (_anySubPlaying) {
const _subs = subTabsRef.current || [];
for (let _si = 0; _si < _subs.length; _si++) {
const s = _subs[_si];
if (!s.isPlaying) continue;
if (s.buffer) {
if (activeSourcesRef.current.some(src => typeof src.startTime === 'number' && ctxNow >= src.startTime && ctxNow <= src.startTime + (src.buffer ? src.buffer.duration : 0) + 0.1)) { anyPlaying = true; break; }
}
}
} else {
anyPlaying = activeSourcesRef.current.some(s => typeof s.startTime === 'number' && ctxNow >= s.startTime && ctxNow <= s.startTime + (s.buffer ? s.buffer.duration : 0) + 0.1);
}
if (anyPlaying) { if (anyPlaying) {
const d = new Uint8Array(128); const d = new Uint8Array(128);
masterBus.analyser.getByteTimeDomainData(d); masterBus.analyser.getByteTimeDomainData(d);
@@ -17672,9 +17769,25 @@ 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(); } catch (e) { console.warn('[Recovery] initMasterBus error:', e); } try { initMasterBus(getAudioContext()); } catch (e) { console.warn('[Recovery] initMasterBus error:', e); }
setIsPlaying(true); // Resume ĐÚNG chế đ play hin ti (main hoc sub-tab piano roll)
startTrackPlayback(pt); const curTab = activeTabRef.current;
const subSt = subTabsRef.current.find(s => s.id === curTab);
if (subSt && (subSt.type === 'PIANO_ROLL' || subSt.buffer)) {
const resumeAt = subSt.currentTime || 0;
if (subSt.type === 'PIANO_ROLL') schedulePianoRollMidi(subSt, resumeAt);
startSubTabPlayback(subSt, resumeAt);
setSubTabs(prev => prev.map(s => s.id === curTab ? {
...s,
buffer: s.buffer || getAudioContext().createBuffer(1, 128, getAudioContext().sampleRate),
isPlaying: true,
currentTime: resumeAt
} : s));
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else {
setIsPlaying(true);
startTrackPlayback(pt);
}
} catch (e) { console.warn('[Recovery] rebuild error:', e); } } catch (e) { console.warn('[Recovery] rebuild error:', e); }
} }
} else { } else {
@@ -17762,6 +17875,7 @@ const App = () => {
stopAllPlayback(); stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
...s, ...s,
buffer: s.buffer || getAudioContext().createBuffer(1, 128, getAudioContext().sampleRate),
currentTime: start, currentTime: start,
isPlaying: true isPlaying: true
} : s)); } : s));
@@ -17788,9 +17902,13 @@ const App = () => {
if (st.isLooping) { if (st.isLooping) {
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
...s, ...s,
buffer: s.buffer || getAudioContext().createBuffer(1, 128, getAudioContext().sampleRate),
currentTime: 0, currentTime: 0,
isPlaying: true isPlaying: true
} : s)); } : s));
// Loop li: phi schedule notes MI (âm piano roll mt ln loop 2
// nếu ch chy silent buffer).
if (st.type === 'PIANO_ROLL') schedulePianoRollMidi(st, 0);
startSubTabPlayback(st, 0); startSubTabPlayback(st, 0);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else { } else {
@@ -18049,7 +18167,10 @@ const App = () => {
const nodeAudibleGain = computeTrackAudibleGain(trackList, track); const nodeAudibleGain = computeTrackAudibleGain(trackList, track);
setTrackNodeGain(node, nodeAudibleGain); setTrackNodeGain(node, nodeAudibleGain);
if (node.sfEntry) { if (node.sfEntry) {
try { node.sfEntry.gain.setTargetAtTime(nodeAudibleGain, context.currentTime, 0.02); } catch (e) {} try {
const g = (typeof nodeAudibleGain === 'number' && isFinite(nodeAudibleGain) && !isNaN(nodeAudibleGain)) ? nodeAudibleGain : 1.0;
node.sfEntry.gain.setTargetAtTime(g, context.currentTime, 0.02);
} catch (e) {}
} }
activeTrackNodesRef.current[track.id] = node; activeTrackNodesRef.current[track.id] = node;
console.log('[Bypass] node created track', track.id, 'initial audioBypass=', !!trackAudioBypassMap[track.id], 'routeGain=', node.route.routeGain.gain.value, 'dryGain=', node.route.dryGain.gain.value); console.log('[Bypass] node created track', track.id, 'initial audioBypass=', !!trackAudioBypassMap[track.id], 'routeGain=', node.route.routeGain.gain.value, 'dryGain=', node.route.dryGain.gain.value);
@@ -18113,6 +18234,25 @@ const App = () => {
const updateSfRouting = () => { const updateSfRouting = () => {
try { try {
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks; const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
// PIANO_ROLL tab đang active ưu tiên route FluidSynth ti NODE CA
// TRACK đang edit (sfEntry FX Rack riêng, fallback gainNode) đ fader/
// pan/FX áp đúng cho notes đang nghe k c khi project có >1 track MIDI
// audible (bình thưng rơi v masterBus.input, mt FX track).
const _activeTabId = activeTabRef.current;
const _activeSub = subTabsRef.current ? subTabsRef.current.find(s => s.id === _activeTabId) : null;
if (_activeSub && _activeSub.type === 'PIANO_ROLL') {
const _prNode = activeTrackNodesRef.current[_activeSub.trackId];
if (_prNode && window.SonicSF && window.SonicSF.setOutputDestination) {
if (_prNode.sfEntry) {
window.SonicSF.setOutputDestination(_prNode.sfEntry);
return;
}
if (_prNode.gainNode) {
window.SonicSF.setOutputDestination(_prNode.gainNode);
return;
}
}
}
const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0); const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0);
if (midiAudible.length === 1) { if (midiAudible.length === 1) {
const t = midiAudible[0]; const t = midiAudible[0];
@@ -18579,12 +18719,20 @@ const App = () => {
const secondsPerBeat = 60.0 / bpmVal; const secondsPerBeat = 60.0 / bpmVal;
const startWallTime = context.currentTime; const startWallTime = context.currentTime;
const track = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === st.trackId) : null; const track = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === st.trackId) : null;
if (!track) { console.warn('[Play] Piano Roll track not found:', st.trackId); return; }
const destNode = getOrCreateTrackNode(track, context); const destNode = getOrCreateTrackNode(track, context);
const instrumentProgram = track ? track.instrumentProgram : undefined; // Instrument context resolve t TRACK live (ngun duy nht) track "đã
const synthEngine = track ? track.synth_engine : undefined; // loaded instrument" (instrumentProgram GM hoc synth_engine soundfont)
// phi chơi ĐÚNG instrument đó. ensureSonicInstrument select channel đúng
// trưc khi notes bn (fire-and-forget playNote t load+retry nếu SF
// chưa xong).
const instCtx = resolveTrackInstrumentCtx(track, activeTracksRef.current || []);
const instrumentProgram = instCtx.program;
const synthEngine = instCtx.synthEngine;
const mainCh = instCtx.ch;
var allTracks = activeTracksRef.current || []; var allTracks = activeTracksRef.current || [];
var mainCh = track ? assignTrackMidiChannel(track, allTracks) : 0; ensureSonicInstrument(instCtx);
// The piano-roll playhead (st.currentTime) and transport are item-relative: console.log('[Play] PianoRoll schedule:', st.id, '| notes=', midiNotes.length, '| track=', st.trackId, '| program=', instrumentProgram, '| sf=', instCtx.sfId ? instCtx.sfId : '-', '| dest=', destNode ? 'ok' : 'null', '| ch=', mainCh, '| sfEngine=', synthEngine ? 'yes' : 'no', '| bypassA=', !!trackAudioBypassMap[st.trackId], '| bypassMidi=', !!trackMidiBypassMap[st.trackId]);
// 0 = item start. Notes must be scheduled relative to the item too, otherwise // 0 = item start. Notes must be scheduled relative to the item too, otherwise
// items placed later in the project play `item.startTime` seconds in the // items placed later in the project play `item.startTime` seconds in the
// future (silence when pressing play). Ghost notes are already relative to // future (silence when pressing play). Ghost notes are already relative to
@@ -18631,6 +18779,7 @@ const App = () => {
}); });
}; };
const handlePlayPause = () => { const handlePlayPause = () => {
console.log('[Play] click activeTab=', activeTab, 'isPlaying=', isPlaying, 'subPlaying=', subTabs.filter(s => s.isPlaying).length);
if (activeTab !== 'main' && !activeTab.startsWith('session_')) { if (activeTab !== 'main' && !activeTab.startsWith('session_')) {
// Sub-tab playback transport // Sub-tab playback transport
const st = subTabs.find(s => s.id === activeTab); const st = subTabs.find(s => s.id === activeTab);
@@ -18642,21 +18791,28 @@ const App = () => {
isPlaying: false isPlaying: false
} : s)); } : s));
} else { } else {
stopAllPlayback(); try {
const startOffset = st.currentTime || 0; stopAllPlayback();
const startOffset = st.currentTime || 0;
if (st.type === 'PIANO_ROLL') { if (st.type === 'PIANO_ROLL') {
schedulePianoRollMidi(st, startOffset); schedulePianoRollMidi(st, startOffset);
startSubTabPlayback(st, startOffset); startSubTabPlayback(st, startOffset);
} else { } else {
startSubTabPlayback(st, startOffset); startSubTabPlayback(st, startOffset);
}
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
...s,
buffer: s.buffer || getAudioContext().createBuffer(1, 128, getAudioContext().sampleRate),
isPlaying: true,
currentTime: startOffset
} : s));
// BT BUC start rAF loop updatePlayhead nếu không, playhead sub-tab
// không di chuyn (và loop/stop không bao gi chy).
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} catch (e) {
console.error('[Play] Piano Roll play error:', e);
showToast('Lỗi phát Piano Roll: ' + e.message, 'error');
} }
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
...s,
isPlaying: true,
currentTime: startOffset
} : s));
} }
return; return;
} }
@@ -18716,6 +18872,7 @@ const App = () => {
if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback();
}; };
const stopAllPlayback = () => { const stopAllPlayback = () => {
try {
activeSourcesRef.current.forEach(src => { activeSourcesRef.current.forEach(src => {
try { try {
src.stop(); src.stop();
@@ -18723,12 +18880,17 @@ const App = () => {
}); });
activeSourcesRef.current = []; activeSourcesRef.current = [];
Object.values(activeTrackNodesRef.current).forEach(n => { Object.values(activeTrackNodesRef.current).forEach(n => {
if (n.fxStopFn) n.fxStopFn(); if (n.fxStopFn) { try { n.fxStopFn(); } catch (e) {} }
}); });
activeTrackNodesRef.current = {}; activeTrackNodesRef.current = {};
stopMidiCapture(); stopMidiCapture();
if (window.SonicSF) { if (window.SonicSF) {
window.SonicSF.stopAll(); try { window.SonicSF.stopAll(); } catch (e) { console.warn('[Stop] stopAll error:', e); }
// Dng trit đ: noteoff tng note + hy scheduled note-on (hết âm stuck)
if (window.SonicSF.panic) { try { window.SonicSF.panic(); } catch (e) { console.warn('[Stop] panic error:', e); } }
}
} catch (e) {
console.warn('[Stop] stopAllPlayback error:', e);
} }
setIsPlaying(false); setIsPlaying(false);
setSubTabs(prev => prev.map(s => ({ setSubTabs(prev => prev.map(s => ({
File diff suppressed because one or more lines are too long
+66 -11
View File
@@ -69,9 +69,12 @@
try { try {
if (_gainNode) { if (_gainNode) {
_gainNode.disconnect(); _gainNode.disconnect();
_gainNode.connect(node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination))); const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
_gainNode.connect(dest);
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
} else { } else {
_pendingOutputDestination = node || null; _pendingOutputDestination = node || null;
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
} }
} catch (e) { } catch (e) {
console.warn('[SonicSF] setOutputDestination error:', e); console.warn('[SonicSF] setOutputDestination error:', e);
@@ -269,6 +272,12 @@
_doLoadSoundFont: async function (sfId) { _doLoadSoundFont: async function (sfId) {
try { try {
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
// thành rác trong khi channel state vẫn trỏ tới → program_select
// bị skip (progAlreadySet) → noteon trên handle đã unload →
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
var cache = window.SonicSFStorage; var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null; var buf = cache ? await cache.getBuffer(sfId) : null;
if (buf) { if (buf) {
@@ -449,10 +458,11 @@
}, },
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) { _playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60)); var parsedPitch = parseInt(note);
var midiVel = Math.min(127, Math.max(1, Math.floor( var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100 var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
))); if (isNaN(rawVel)) rawVel = 100;
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
var _origChannel = channel; var _origChannel = channel;
var usedBank = 0, usedProg = 0; var usedBank = 0, usedProg = 0;
if (synthEngine) { if (synthEngine) {
@@ -485,8 +495,10 @@
var self = this; var self = this;
var doNote = function () { var doNote = function () {
try { try {
var finalBank = usedBank; var finalBank = parseInt(usedBank);
var finalProg = usedProg; if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
var finalProg = parseInt(usedProg);
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined; var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
var cachedCh = _channels[ch]; var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is // The note's own synth engine (track instrument) is
@@ -495,15 +507,17 @@
// re-picked instrument plays the wrong soundfont. Without an // re-picked instrument plays the wrong soundfont. Without an
// engine, fall back to the soundfont configured on the channel. // engine, fall back to the soundfont configured on the channel.
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) { if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
finalBank = cachedCh.bank; finalBank = parseInt(cachedCh.bank) || 0;
finalProg = cachedCh.program; finalProg = parseInt(cachedCh.program) || 0;
finalSfId = cachedCh.sfId; finalSfId = cachedCh.sfId;
} }
// Ensure the soundfont is actually loaded before the note plays. // Ensure the soundfont is actually loaded before the note plays.
// 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)) {
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);
if (ok) doNote(); if (ok) doNote();
}); });
return; return;
@@ -512,9 +526,13 @@
// instrument for each item regardless of processing order. // instrument for each item regardless of processing order.
// Skip if the channel already has this exact instrument (avoids // Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches). // per-note soundfont reloads that cause audible crackle/glitches).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId; // ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
// không → vẫn program_select lại (tránh dùng handle đã unload).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
if ((synthEngine || program !== undefined) && !progAlreadySet) { if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined; var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
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); _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
@@ -528,6 +546,7 @@
_channels[ch].program = finalProg; _channels[ch].program = finalProg;
_channels[ch].sfId = finalSfId; _channels[ch].sfId = finalSfId;
} }
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel); _fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch; var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = []; if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
@@ -558,6 +577,31 @@
} }
}, },
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
// tự động → ngân vô hạn → VU master nhảy dù không play).
panic: function () {
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
_scheduledNotes = [];
if (_initialized && _fluidModule) {
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
// thể không có trong WASM exports (catch nuốt → notes kẹt).
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
try {
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
} catch (e) {}
}
_activeNotes = {};
},
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) { _playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
var ctx = getCtx(); var ctx = getCtx();
var freq = 440 * Math.pow(2, (note - 69) / 12); var freq = 440 * Math.pow(2, (note - 69) / 12);
@@ -592,7 +636,8 @@
osc.frequency.setValueAtTime(freq, 0); osc.frequency.setValueAtTime(freq, 0);
var startAt = startTime !== undefined ? startTime : ctx.currentTime; var startAt = startTime !== undefined ? startTime : ctx.currentTime;
var durSec = durationMs / 1000; var durSec = durationMs / 1000;
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
if (isNaN(vel)) vel = 0.8;
var targetGain = vel * volFactor; var targetGain = vel * volFactor;
noteGain.gain.setValueAtTime(0, startAt); noteGain.gain.setValueAtTime(0, startAt);
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime); noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
@@ -613,6 +658,15 @@
stopAll: function () { stopAll: function () {
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
// phòng all_notes_off không có trong WASM exports.
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
for (var ch = 0; ch < 16; ch++) { for (var ch = 0; ch < 16; ch++) {
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {} try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
} }
@@ -631,6 +685,7 @@
} catch (e) {} } catch (e) {}
}); });
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; }); Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
_activeNotes = {};
}, },
saveToIndexedDB: async function (name, arrayBuffer) { saveToIndexedDB: async function (name, arrayBuffer) {
+2 -2
View File
@@ -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=202608031400"></script> <script src="/static/js/services/soundfontPlayer.js?v=202608042158"></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=202608038600" defer></script> <script src="/static/js/app.precompiled.js?v=202608042200" 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 {
+134 -1
View File
@@ -1,4 +1,90 @@
### [2026-08-03] Task: Save project MẤT màu track — serializeTracksList thiếu color + schema ### [2026-08-04] Task: PIANO ROLL play ĐÚNG instrument track — resolveTrackInstrumentCtx + ensureSonicInstrument (nguồn duy nhất)
- **Tóm tắt thay đổi:** User yêu cầu "mở PIANO ROLL TAB của track đã loaded instrument → PHẢI play note với ĐÚNG instrument đó". Trước đây luồng instrument rời rạc: `schedulePianoRollMidi` dùng track live nhưng preview (wheel/click/keybed) dùng `st.instrumentProgram` — snapshot STALE từ lúc mở tab (đổi instrument sau khi mở tab → preview nghe instrument cũ/sai) + chưa có nơi nào chủ động select đúng channel trước khi note bắn. Viết lại từ đầu:
1. **`resolveTrackInstrumentCtx(track, tracks)`** (module-level, nguồn duy nhất): resolve instrument từ TRACK live — ưu tiên `synth_engine` (soundfont: soundfont_id/bank/program → SF path, program=undefined để _playNoteFluid ưu tiên synthEngine), fallback `instrumentProgram` (GM preset), không có → im (đúng — chưa chọn instrument).
2. **`ensureSonicInstrument(ctx)`**: fire-and-forget `selectInstrument(ch, bank, prog, sfId)` — đảm bảo channel của track đã select ĐÚNG instrument trước khi notes bắn (playNote tự load+retry nếu SF chưa xong — dedup sẵn, không stall).
3. Áp vào `schedulePianoRollMidi` + 4 preview paths (canvas wheel 7124, click note 7459, draw brush 7780, keybed 8097/8120/8133) — bỏ `st.instrumentProgram`/`kbCh`/`kbSynth`/`pvCh` cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (helpers + 4 call sites), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042200), `app/static/js/services/soundfontPlayer.js` (log setOutputDestination — sibling)
- **Ghi chú/Test (nếu có):** BUILD OK 1013347 bytes, node --check OK, smoke test resolveTrackInstrumentCtx 4 case (SF/GM/no-instr/vst3-empty) đúng. `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only).
---
### [2026-08-04] Task: Fix mất âm toàn cục khi mở PIANO ROLL TAB + bấm play — exempt watchdog + SF routing theo active tab
- **Tóm tắt thay đổi:** User báo "nhấp đôi MIDI item mở PIANO ROLL TAB, điều khiển transport → mất âm, không còn âm ra loa". Root cause: master-silence watchdog trong updatePlayhead coi PIANO_ROLL (chỉ có notes MIDI schedule rời rạc + silent source 2.9ms) là "play mà im lặng" → mọi rest >750ms trigger rebuild → `stopAllPlayback()` + `panic()` hủy notes đang chờ (SF lazy-load lần đầu / gap tự nhiên) → rebuild loop mỗi 3s → notes không bao giờ bắn → CÂM TOÀN CỤC. Fix 2 change:
1. **Exempt PIANO_ROLL khỏi watchdog** (`_isPianoRollWatchdog` — active sub-tab type PIANO_ROLL → bỏ qua toàn bộ block). PIANO_ROLL không có nguồn liên tục → im lặng là tự nhiên; các fix setValueAtTime/NaN guard đã hết "BiquadFilter state is bad" nên watchdog chỉ còn là lớp cứu cuối cho main/audio-tab.
2. **updateSfRouting ưu tiên active PIANO_ROLL track**: khi tab PIANO_ROLL đang active → route FluidSynth tới node của track đang edit (`sfEntry` → FX Rack riêng, fallback `gainNode`) — kể cả khi project có >1 track MIDI audible (bình thường fallback masterBus.input, mất fader/pan/FX track). Fader + FX áp đúng cho notes đang nghe.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (watchdog exempt + updateSfRouting), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608041200)
- **Ghi chú/Test (nếu có):** BUILD OK 1010058 bytes (build.mjs standalone — Babel 8 ESM conflict), node --check OK, `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only). Manual: dblclick MIDI item → play → âm chạy liên tục qua rest; fader/FX track áp đúng.
---
### [2026-08-03] Task: Watchdog sub-tab hoạt động (silent source 2.9ms làm anyPlaying luôn false) + log applyMasteringSettings
- **Tóm tắt thay đổi:** STOP piano roll vẫn 11× "state is bad" (guard NaN + hết automation KHÔNG đủ — flag dồn tích trên biquad từ trước). Kiểm tra: applyMasteringSettings ĐÃ có sig guard + clamp NaN sẵn; getAudioContext sạch (chỉ masteringSettings effect quản lí) → warning là flag PERSISTENT trên masterBus biquad — **cứu bằng watchdog rebuild**. Fix:
1. **Watchdog sub-tab**: silent source piano roll chỉ dài ~2.9ms → `anyPlaying` luôn false sau 100ms → watchdog VÔ HIỆU với piano roll. Sửa: `anyPlaying = _anySubPlaying ? true : ...` — sub-tab play + master im lặng >750ms → rebuild + resume đúng chế độ (log `[Recovery]`).
2. **Log `[Mastering] applyMasteringSettings active=...`** — theo dõi khi nào apply chạy (sig guard — chỉ khi settings đổi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608040000)
- **Ghi chú/Test (nếu có):** BUILD OK 1008644 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Play main OK → space (piano roll) OK (hết warning — bản 39800 hiệu lực) → **STOP → 1 warning "BiquadFilterNode: state is bad" → câm + không còn log play tiếp**. Phát hiện: "state is bad" KHÔNG chỉ do automation — **setValueAtTime(NaN) TRÊN BIQUAD cũng gây flag** (NaN từ field settings undefined → clamp(undefined)=NaN). Fix:
1. **applyMasteringSettings (masterBus eq filters)**: `_g(v, lo, hi)` guard `typeof v === 'number' && isFinite(v)` — NaN/undefined → 0.
2. **`eqproClamp` guard isFinite** — EQ PRO mọi giá trị biquad an toàn (NaN → lo).
3. **Log `[Play] click activeTab=...`** đầu handlePlayPause — biết play sau stop có chạy không + rơi nhánh nào.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039900)
- **Ghi chú/Test (nếu có):** BUILD OK 1008312 bytes, node --check OK, `pytest` 86 passed. Guard NaN ✓ (isFinite ×3), log click ✓.
---
- **Tóm tắt thay đổi:** Play piano roll tab → 12 warning `BiquadFilterNode: state is bad` liên tục + instrument không phát + MAIN OUT treo. 2 fix:
1. **`applyEQPreset` (10628)**: `setTargetAtTime(..., 0.02)` trên masterBus EQ biquads → **setValueAtTime****loại nguồn automation biquad cuối cùng** (EQ PRO/masterBus eq/applyEQPreset/track FX — tất cả đã setValueAtTime; các setTargetAtTime còn lại đều gain/compressor/limiter).
2. **Watchdog master-silence MỞ RỘNG cho sub-tab**: guard cũ `activeTab === 'main'` bỏ + thêm `_anySubPlaying` (sub-tab play không set isPlaying App) → piano roll câm → **tự rebuild + resume ĐÚNG chế độ** (sub-tab: schedulePianoRollMidi + startSubTabPlayback; main: startTrackPlayback) → **MAIN OUT treo tự phục hồi sau ~750ms + cooldown 3s** (log `[Recovery]`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039800)
- **Ghi chú/Test (nếu có):** BUILD OK 1007948 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log mới: 5 track play OK (node ok, muted false), piano roll schedule OK — nhưng warning `BiquadFilterNode: state is bad` vẫn xuất hiện. Nguồn cuối cùng: **masterBus EQ filters (eqLow/Mid1/Mid2/High — BiquadFilter) dùng `setTargetAtTime(..., 0.05)`** trong applyMasteringSettings — đổi toàn bộ sang **`setValueAtTime(x, now)`** (cancelScheduledValues giữ + bỏ tham số thứ 3) → hết automation trên mọi biquad → hết warning (các setTargetAtTime còn lại đều là gain/compressor/limiter — không gây "state is bad").
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039700)
- **Ghi chú/Test (nếu có):** BUILD OK 1007264 bytes, node --check OK, `pytest` 86 passed. eq filters setValueAtTime ✓, hết eq setTargetAtTime ✓.
---
- **Tóm tắt thay đổi:** Lỗi FluidSynth `Instrument not found on channel 0 [bank=0 prog=50], substituted [bank=0 prog=0]` — do fix unload SF (39500) gây ra: SGM bị `sfunload` + xóa khỏi `_sfHandleMap` → channel state (`cachedCh.sfId`) vẫn trỏ SGM → `progAlreadySet=true`**skip program_select** → noteon trên **handle đã unload** → "Instrument not found" + substitute prog 0 (âm ra nhưng sai nhạc cụ). Fix (soundfontPlayer.js):
1. **BỎ unload SF cũ khi sfload SF mới** — heap 256MB đủ cho vài SF (log: SGM handle 1 + latin handle 2 load OK); unload tạo handle rác.
2. **`progAlreadySet` thêm điều kiện `_sfHandleMap.has(finalSfId)`** — chỉ skip program_select khi handle còn hợp lệ (505 đã đảm bảo load xong trước khi tới 515).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` (bump v=202608039600 — app.precompiled giữ nguyên 39500)
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed. Hết sfunload ✓, progAlreadySet check handle ✓.
---
- **Tóm tắt thay đổi:** "MỌI âm thanh không còn phát sau khi mở PIANO ROLL TAB" — thủ phạm: preload `loadSoundFont(soundfont_id)` tôi thêm ở bản 39300 khi mở tab → **sfload SF mới nền → WASM heap 256MB đầy → FluidSynth stall → silence toàn cục** (đúng comment loadSoundFont "stalling notes... then silence"). Fix:
1. **BỎ preload loadSoundFont khỏi handleEditMidiInTab** — playNote TỰ load + retry đúng lúc note cần (không load khi chỉ mở tab).
2. **soundfontPlayer `_doLoadSoundFont`: UNLOAD SF cũ trước khi sfload SF mới** (`_fluid_synth_sfunload` + `_sfHandleMap.delete` + `_loadedFonts=false`) — heap không tích nhiều SF; lần sau cần lại SF cũ → tự re-load.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (unload trước sfload), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039500)
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Log `[Play] PianoRoll schedule ... notes=248 ...` CHẠY nhưng nút play/space không phản hồi (nút không đổi trạng thái). Nguyên nhân: `stopAllPlayback()` (gọi đầu nhánh play) — **`n.fxStopFn()` (18735) KHÔNG bọc try** — nếu throw → exception lan ra catch của handlePlayPause → `setSubTabs(isPlaying: true)` KHÔNG chạy → nút play không đổi (vô tác dụng) + toast lỗi. Fix: **bọc toàn bộ thân stopAllPlayback bằng try/catch** (log `[Stop] ...`) + `fxStopFn`/`SonicSF.stopAll`/`panic` mỗi cái bọc try riêng → stopAllPlayback KHÔNG BAO GIỜ throw → setSubTabs luôn chạy → nút play phản hồi đúng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039400)
- **Ghi chú/Test (nếu có):** BUILD OK 1007546 bytes, node --check OK, `pytest` 86 passed. fxStopFn bọc try ✓ (18737).
---
- **Tóm tắt thay đổi:** "Không thể play midi với instrument đã loaded trong PIANO ROLL TAB". Nguyên nhân chính: `playNote``_doLoadSoundFont``fetch /api/v1/plugins/soundfonts/download/<sfId>`**nếu `soundfont_id` (sfClean — bỏ 'sf_') sai/không có trên server → 404 → return false → `doNote` KHÔNG chạy → note không phát** (câm với instrument đó). Selector instrument cũng KHÔNG load SF (chỉ list presets) — playNote tự load + retry. Fix: **preload soundfont NỀN khi mở tab** (`loadSoundFont(soundfont_id)` fire-and-forget — không chặn play, dedup sẵn) — note đầu không trễ; nếu id sai → console `[SonicSF] SoundFont not found: <id>` để chẩn đoán ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039300)
- **Ghi chú/Test (nếu có):** BUILD OK 1007356 bytes, node --check OK, `pytest` 86 passed. Nếu vẫn câm → dán console `[Play] PianoRoll schedule` + `[SonicSF] SoundFont loaded/not found`.
---
- **Tóm tắt thay đổi:** Play MIDI item trong PIANO ROLL TAB → âm bị stuck (ngân mãi) + VU master vẫn nhảy dù không play. Nguyên nhân khả dĩ: `stopAll`/dừng chỉ gọi `_fluid_synth_all_notes_off`**binding này có thể KHÔNG tồn tại trong WASM exports** (catch nuốt → notes kẹt ngân vô hạn; note-on scheduled tương lai không bị hủy). Fix (soundfontPlayer.js):
1. **`stopAll` + `panic()` (mới)**: **noteoff TỪNG note đang ngân qua `_fluid_synth_noteoff`** (binding chắc chắn tồn tại — đã dùng khi duration hết) + clearTimeout mọi scheduled note-on tương lai + all_notes_off (phòng hờ) + reset `_activeNotes` (tránh noteoff lặp).
2. Gọi `panic()` từ stopAllPlayback (18740 — bên cạnh SonicSF.stopAll()).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (panic + stopAll noteoff từng note), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039100 cả soundfontPlayer.js)
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** User dán log `[Bypass] node created track 1 initial audioBypass= true routeGain= 0 dryGain= 1`**đây là INFO BÌNH THƯỜNG**: track 1 đang bật bypass (nút A xám) → âm đi **dry path** (dryInput → dryOutput → output → destination — vẫn ra main out, qua master fader). Thêm **diagnostic `[Play] PianoRoll schedule`** (notes count, track, program, dest ok/null, channel, sfEngine, bypassA, bypassMidi) vào schedulePianoRollMidi — user dán log này để xác định chính xác notes có được schedule không.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039000)
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Piano Roll play: playhead không di chuyển + không âm. Nguyên nhân: **handlePlayPause nhánh sub-tab KHÔNG gọi `animationFrameIdRef.current = requestAnimationFrame(updatePlayhead)`** (mọi nơi khác đều có — 13491/17769/17795...) → rAF loop không chạy → playhead đứng im + loop/stop sub-tab không bao giờ kích hoạt. Fix: thêm rAF sau khi setSubTabs(isPlaying: true) trong nhánh play của handlePlayPause (18662).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038900)
- **Ghi chú/Test (nếu có):** BUILD OK 1006951 bytes, node --check OK, `pytest` 86 passed. rAF count 10 ✓.
---
- **Tóm tắt thay đổi:** Double-click MIDI item mở PIANO ROLL → không âm (cả tab lẫn MAIN/SECTION sau đó). Nguyên nhân: `handleEditMidiInTab` gọi **`SonicSF.selectInstrument(seCh, ...)`** — **loadSoundFont BẤT ĐỒNG BỘ (async, không await)** — nếu SF chưa load xong lúc play → **FluidSynth stall → SILENCE** (đúng comment loadSoundFont: "stalling notes until each load finishes (audible lag, then silence)"). Nhạc cụ đã được chọn đúng khi playNote (track.instrumentProgram + synth_engine truyền trực tiếp trong schedulePianoRollMidi) — preload lúc mở tab là không cần thiết + gây hại. **Fix: BỎ khối selectInstrument khỏi handleEditMidiInTab.**
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038800)
- **Ghi chú/Test (nếu có):** BUILD OK 1006759 bytes, node --check OK, `pytest` 86 passed. selectInstrument(seCh — không còn khi mở tab ✓.
---
- **Tóm tắt thay đổi:** "Mở PIANO ROLL TAB không nhấn play được + không âm" — nhóm fix:
1. **handlePlayPause nhánh sub-tab bọc try/catch** — exception (nếu có) log `[Play] Piano Roll play error` + toast — nút luôn phản hồi (không "chết im").
2. **schedulePianoRollMidi guard `!track`** — log warn + return (tránh throw khi track không còn).
3. **Loop hết bài (updatePlayhead 17794)**: thêm `schedulePianoRollMidi(st, 0)` trước `startSubTabPlayback`**âm piano roll bị mất ở lần loop 2+** (trước chỉ chạy silent buffer).
4. **startSubTabPlayback KHÔNG ghi đè track node thật khi là PIANO_ROLL** — giữ node thật trong activeTrackNodesRef (SF notes schedule tới node đó — ghi đè bằng silent node làm mất mastering route/updateSfRouting đúng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038700)
- **Ghi chú/Test (nếu có):** BUILD OK 1006797 bytes, node --check OK, `pytest` 86 passed. try/catch ✓, guard ✓, loop schedule notes ✓, giữ node thật ✓.
---
- **Tóm tắt thay đổi:** Save project (Cloud/.sfs) → reload → mất màu track. Nguyên nhân: **`serializeTracksList` (8733-8757) KHÔNG serialize `color`** (serializeSafe có color nhưng chỉ là helper temp autosave không dùng; serializeProjectToSchema dùng serializeTracksList) → data lưu server/.sfs không có màu → deserialize (8825 có `color: t.color`) nhận null → mất. Fix: - **Tóm tắt thay đổi:** Save project (Cloud/.sfs) → reload → mất màu track. Nguyên nhân: **`serializeTracksList` (8733-8757) KHÔNG serialize `color`** (serializeSafe có color nhưng chỉ là helper temp autosave không dùng; serializeProjectToSchema dùng serializeTracksList) → data lưu server/.sfs không có màu → deserialize (8825 có `color: t.color`) nhận null → mất. Fix:
1. `serializeTracksList`: thêm **`color: t.color || null`** vào track-level fields. 1. `serializeTracksList`: thêm **`color: t.color || null`** vào track-level fields.
2. `app/models/project_schema.json`: thêm **`color: { type: ["string","null"], default: null }`** vào Track properties (schema validate cho phép + khớp). 2. `app/models/project_schema.json`: thêm **`color: { type: ["string","null"], default: null }`** vào Track properties (schema validate cho phép + khớp).
@@ -1715,3 +1801,50 @@
- **Tóm tắt thay đổi:** (1) **Auto set tempo**: click MIDI file → `handleSelect` set tempo từ metadata `f.bpm`; `playMidiPreview` sau khi parse set tempo theo `midiResult[0].bpm` (clamp 40-300) trước khi schedule → preview phát đúng tempo file. (2) **Gõ tempo tay**: input tempo dùng `tempoText` (string) cho phép gõ tự do (trước đây clamp 40-300 ngay khi gõ chặn việc nhập số < 40), commit khi hợp lệ hoặc blur/Enter; `commitTempo` còn re-schedule MIDI preview đang phát theo tempo mới. (3) **Focus folder cha**: click file → expand các node cha + `centerTreeNodeInPane` cuộn tree pane (ref `treePaneRef`) để folder cha hiện GIỮA ô tree. (4) **Phím mũi tên**: khi panel active (`window.mediaExplorerActive`) và ở computer mode, ArrowUp/Down di chuyển cursor qua node hiển thị (dùng `computerPathRef`/`computerTreeRef`/`computerRootsRef` để tránh stale closure trong keydown `[]`), ArrowRight expand/load, ArrowLeft collapse hoặc về thư mục cha; `browseComputerDirRef` tránh stale `browseComputerDir`. - **Tóm tắt thay đổi:** (1) **Auto set tempo**: click MIDI file → `handleSelect` set tempo từ metadata `f.bpm`; `playMidiPreview` sau khi parse set tempo theo `midiResult[0].bpm` (clamp 40-300) trước khi schedule → preview phát đúng tempo file. (2) **Gõ tempo tay**: input tempo dùng `tempoText` (string) cho phép gõ tự do (trước đây clamp 40-300 ngay khi gõ chặn việc nhập số < 40), commit khi hợp lệ hoặc blur/Enter; `commitTempo` còn re-schedule MIDI preview đang phát theo tempo mới. (3) **Focus folder cha**: click file → expand các node cha + `centerTreeNodeInPane` cuộn tree pane (ref `treePaneRef`) để folder cha hiện GIỮA ô tree. (4) **Phím mũi tên**: khi panel active (`window.mediaExplorerActive`) và ở computer mode, ArrowUp/Down di chuyển cursor qua node hiển thị (dùng `computerPathRef`/`computerTreeRef`/`computerRootsRef` để tránh stale closure trong keydown `[]`), ArrowRight expand/load, ArrowLeft collapse hoặc về thư mục cha; `browseComputerDirRef` tránh stale `browseComputerDir`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa commitTempo/navigateTreeTo/centerTreeNodeInPane/getVisibleTreePaths/treePaneRef/tempoText. Harness `node /tmp/kilo/test_tree.js` mô phỏng flatten tree + Up/Down/Left logic — ALL PASSED. 9 harness còn lại PASS. Hard refresh. - **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa commitTempo/navigateTreeTo/centerTreeNodeInPane/getVisibleTreePaths/treePaneRef/tempoText. Harness `node /tmp/kilo/test_tree.js` mô phỏng flatten tree + Up/Down/Left logic — ALL PASSED. 9 harness còn lại PASS. Hard refresh.
### [2026-08-04 21:35] Task: Fix mất âm thanh trong Piano Roll và lỗi BiquadFilterNode state is bad khi play MIDI
- **Tóm tắt thay đổi:** Khắc phục triệt để lỗi mất âm thanh và lỗi filter master bị hỏng (BiquadFilterNode state is bad):
(1) **Chặn NaN Pitch/Velocity trong soundfontPlayer.js**: Khi playNote nhận nốt có velocity/pitch là NaN hoặc không hợp lệ (ví dụ do vẽ CC vẽ sai, hoặc AI import lỗi), FluidSynth WASM sẽ nhận giá trị NaN này và xuất ra tín hiệu âm thanh chứa NaN. Tín hiệu NaN này truyền vào Master Bus làm cho toàn bộ 11 bộ lọc BiquadFilterNode bị sụp đổ ("state is bad") và ngắt toàn bộ âm thanh. Đã thêm cơ chế kiểm tra và chuyển NaN về giá trị an toàn mặc định (Pitch -> 60, Velocity -> 100) trong `_playNoteFluid`.
(2) **Định tuyến Piano Roll Tab**: Ưu tiên định tuyến trực tiếp FluidSynth vào track của Piano Roll đang edit trong `updateSfRouting()` thay vì fallback về masterBus.input khi có nhiều hơn 1 track MIDI hoạt động.
(3) **Bảng vá phiên bản (Cache busting)**: Tăng tham số truy vấn cache `v=202608042135` cho `soundfontPlayer.js``app.precompiled.js` trong `index.html` để trình duyệt tải lại tệp tin mới nhất.
- **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. Chạy và kiểm tra nốt nhạc an toàn, không còn hiện tượng NaN lọt vào gây lỗi filter. Tải lại trang (hard refresh) để trình duyệt áp dụng mã nguồn mới.
### [2026-08-04 21:42] Task: Fix mất nhạc cụ khi double click vào Piano Roll Tab sau khi reload dự án
- **Tóm tắt thay đổi:** Khắc phục lỗi khi tải lại trang, phát nhạc cụ trên Main session OK nhưng double-click vào MIDI item để mở Piano Roll thì nhạc cụ bị câm/về mặc định:
(1) **Đồng bộ hóa/Khôi phục synth_engine trong dự án**: Thêm `synth_engine` vào đối tượng tuần tự hóa/giải tuần tự hóa (`serializeProjectToSchema` / `deserializeProjectFromSchema`) của các `sub_tabs`. Trước đây, khi reload dự án, tab con được khôi phục nhưng bị mất thông tin `synth_engine` dẫn đến việc phát nốt nhạc trên bàn phím ảo (keybed) hoặc vẽ nốt không có nhạc cụ.
(2) **Cập nhật động nhạc cụ khi mở lại Tab con**: Trong `handleEditMidiInTab`, nếu tab Piano Roll đã tồn tại (`existing`), tự động cập nhật lại các thuộc tính nhạc cụ (`instrumentProgram`, `instrumentName`, `instrumentId`, `synth_engine`) lấy từ cấu hình hiện tại của track trên Main session.
(3) **Dừng phát Main session khi mở Piano Roll**: Tự động gọi `stopAllPlayback()` khi người dùng double-click mở Piano Roll để đảm bảo không bị kẹt tiến trình phát nền hoặc lỗi đồng bộ.
(4) **Lắng nghe thay đổi Tab để định tuyến**: Thêm `useEffect` để chạy `updateSfRouting()` ngay khi `activeTab` thay đổi để FluidSynth luôn nối đúng đích âm thanh tương ứng với tab đang mở.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
### [2026-08-04 21:46] Task: Fix playhead không di chuyển và không phát tiếng trên Piano Roll Tab khôi phục từ reload
- **Tóm tắt thay đổi:** Khắc phục lỗi khi mở lại Piano Roll Tab được khôi phục từ dự án đã lưu, khi nhấn Play thì playhead không chạy và không phát ra âm thanh:
(1) **Khôi phục Buffer của Sub-tab**: Do `buffer` (đối tượng AudioBuffer) là dữ liệu nhị phân không thể tuần tự hóa sang JSON, khi dự án reload và khôi phục `subTabs` từ DB, trường `st.buffer` của tab con bị `undefined`. Khi `updatePlayhead` chạy, nó kiểm tra điều kiện `if (!st || !st.isPlaying || !st.buffer) return;` — do `st.buffer` bị `undefined`, vòng lặp hoạt họa playhead lập tức bị dừng ngay từ khung hình đầu tiên.
(2) **Đảm bảo Buffer luôn được khởi tạo trong State**: Cập nhật hàm khởi động phát (`handlePlayPause`, recovery resume, looping) để luôn gán hoặc tạo lại một buffer im lặng (`createBuffer`) trong React state `subTabs` nếu phát hiện `buffer` đang bị thiếu. Việc này giúp `updatePlayhead` vượt qua câu lệnh điều kiện và tiếp tục vòng lặp vẽ hoạt họa/phát nhạc thành công.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
### [2026-08-04 21:55] Task: Thêm log chẩn đoán tiến trình định tuyến và nạp nhạc cụ trong soundfontPlayer.js
- **Tóm tắt thay đổi:** Bổ sung các console log chi tiết bên trong `soundfontPlayer.js` nhằm theo dõi chính xác hành vi định tuyến âm thanh và tiến trình nạp nhạc cụ khi chạy Piano Roll:
(1) **Log setOutputDestination**: Ghi nhận thời điểm và đích đến khi định tuyến đầu ra của FluidSynth (`_gainNode` kết nối tới `sfEntry` của track hoặc reset về `masterBus.input`).
(2) **Log loadSoundFont & selectProgram**: Ghi nhận trạng thái nạp SoundFont từ mạng/cache và quá trình chọn program trên kênh MIDI trước khi nốt được phát.
(3) **Log noteon**: Ghi nhận sự kiện phát nốt thực tế bao gồm kênh, tần số/pitch, velocity và soundfont ID của nốt nhạc.
- **Các file ảnh hưởng:** `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.
### [2026-08-04 22:00] Task: Bổ sung bộ lọc NaN và giới hạn tần số Nyquist cho Track FX rack
- **Tóm tắt thay đổi:** Khắc phục lỗi `BiquadFilterNode: state is bad` xảy ra khi chạy các module FX trên track (như EQ, Compressor, Limiter, Exciter):
(1) **Chống giá trị NaN trong Track FX**: Thêm hàm bổ trợ `num(v, def)` để kiểm tra tính hợp lệ (finite và không NaN) của tất cả các tham số cấu hình FX (như EQ gain, drive, mid/side gains, threshold, ratio) trước khi thiết lập giá trị cho Web Audio nodes. Nếu tham số không hợp lệ hoặc bị khôi phục lỗi (ví dụ từ dự án đã lưu), hệ thống sẽ gán giá trị mặc định thay vì NaN, tránh làm hỏng các bộ lọc biquad.
(2) **Giới hạn tần số dưới mức Nyquist**: Áp dụng cơ chế giới hạn tần số `clampF(v) = min(v, sampleRate * 0.45)` cho mọi bộ lọc biquad được tạo ra trong `createTrackFxModule` (cho EQ, Exciter) và `createEqProModule` (EQ nâng cao). Việc này bảo vệ bộ lọc không bị tràn hệ số khi chạy trên các driver âm thanh có sample rate thấp hoặc khi cấu hình tần số quá cao.
(3) **Sửa lỗi khôi phục Master Bus**: Sửa lỗi gọi hàm `initMasterBus()` trong watchdog recovery thiếu tham số `getAudioContext()`.
- **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.
### [2026-08-04 22:05] Task: Bổ sung bộ lọc NaN cho âm lượng track và lựa chọn chương trình nhạc cụ của FluidSynth
- **Tóm tắt thay đổi:** Giải quyết triệt để lỗi `BiquadFilterNode: state is bad` do rò rỉ giá trị `NaN` vào đường truyền âm thanh khi chuyển tiếp sang Piano Roll tab:
(1) **Chống NaN cho âm lượng track**: Cập nhật hàm `computeTrackAudibleGain``setTrackNodeGain` để lọc sạch trường hợp thuộc tính `volumeDb` của track có giá trị `NaN` hoặc dạng chuỗi không hợp lệ, gán giá trị mặc định là `1.0` (0 dB) thay vì trả về `NaN` làm hỏng gain của `sfEntry` / `gainNode`.
(2) **Chống NaN cho chương trình/bank FluidSynth**: Thêm bộ lọc `parseInt``isNaN` kiểm tra biến `usedBank``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`
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.