16 Commits

Author SHA1 Message Date
3dtours 7325fbfc45 fix: câm toàn cục (IN peak có OUT peak không) - WaveShaper luôn identity curve không null + clamp tần số biquad dưới Nyquist + watchdog tự bypass master chain khi hỏng 2026-08-03 12:30:56 +07:00
3dtours 94b2d2ef41 fix: câm toàn cục + BiquadFilterNode state is bad - gỡ mastering khỏi getAudioContext (chỉ áp dụng ở initMasterBus/effect), time constant chậm hơn + cancelScheduledValues, toggleMasteringOnMaster luôn reconnect dù lỗi 2026-08-03 12:26:03 +07:00
3dtours f616edce49 fix: mất âm thanh khi nhấp đôi MIDI item vào Piano Roll - preload instrument bằng dedicated channel (assignTrackMidiChannel) thay vì trkIdx%16 2026-08-03 12:20:20 +07:00
3dtours 6018263044 fix: Lỗi không có âm thanh - quay lại ScriptProcessor vĩnh viễn (worklet push-model bị nghẽn main thread -> câm) 2026-08-03 12:13:26 +07:00
3dtours 36edc9daca fix: bật AudioWorklet hết deprecation ScriptProcessor + signature guard applyMasteringSettings hết BiquadFilterNode state is bad 2026-08-03 12:11:11 +07:00
3dtours 71278f2aba fix: mất soundfont khi ARM - clamp tham số master chain chặn NaN làm biquad state bad, toggleMasteringOnMaster idempotent, loadSoundFont retry khi lỗi thoáng qua 2026-08-03 11:44:13 +07:00
3dtours 022ba38a5e fix: piano roll tab không phát âm thanh - bỏ sessionBeatOffset để note phát theo item-relative khớp playhead (item ở vị trí != 0 trước đây schedule note ở now+startTime -> câm) 2026-08-03 11:34:23 +07:00
3dtours 71c3bafdb5 fix: không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định (worklet gây lỗi thiết bị), giữ worklet mono-safe + cache-busting 2026-08-03 11:24:59 +07:00
3dtours 34ad02dd6b fix: crash AudioWorklet khi output mono - xử lý mọi số channel trong fluidsynth-bridge + ép stereo outputChannelCount=2 2026-08-03 11:20:30 +07:00
3dtours 47b1633bd3 fix: ReferenceError assignTrackMidiChannel - chuyển allocator channel lên module-level cho PianoRollTabEditor + bật AudioWorklet thay ScriptProcessor 2026-08-03 11:17:19 +07:00
3dtours 0182abf7ea fix: play MIDI item luôn dùng instrument của track chứa item - sửa startLocalTrackPlayback thiếu channel/synth_engine + đồng nhất assignTrackMidiChannel mọi đường phát 2026-08-03 11:07:58 +07:00
3dtours 9b6de7f857 fix: sfload reset_presets=0 giữ nguyên instrument các track khác; preloadTrackInstruments module-level (sửa ReferenceError reload); truyền props cho ProfileModal; lọc warning No preset found channel 9 2026-08-03 11:00:08 +07:00
3dtours 84ab4ae823 feat: ARM multitrack mỗi track dùng MIDI channel riêng (0-15, trừ 9) - không làm đổi instrument track khác, lưu midi_channel khi save project 2026-08-03 10:49:41 +07:00
3dtours 25471e6ea5 fix: lag khi ARM sau reload + câm sau vài lần nhấn MIDI - dedup loadSoundFont (1 font=1 sfload, tránh cạn heap 256MB) + preload instrument khi mở/khôi phục dự án 2026-08-03 10:43:26 +07:00
3dtours 0dc95386f2 fix: ARM nhiều track phát sai instrument - synth_engine của track là nguồn quyết định thay vì channel state (bỏ che mask khi channel giữ instrument cũ/trùng) 2026-08-03 10:33:40 +07:00
3dtours d8227904b6 fix: MIDI ARM không phát âm thanh sau khi load instrument - lazy-load soundfont trong playNote + giữ channel khi selectInstrument truyền channel tường minh 2026-08-03 10:23:32 +07:00
6 changed files with 515 additions and 181 deletions
+256 -116
View File
@@ -12,6 +12,37 @@ const API_AUDIO = `${API_BASE_URL}/api/v1/audio`;
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// Dedicated per-track MIDI channel allocation
// FluidSynth has 16 channels; if two tracks share a channel, arming one track
// re-selects the other track's program and its instrument changes. Every track
// gets its own stable, unique channel (0-15, skipping 9 which is the classic
// percussion slot) so multi-track ARM / playback never cross-contaminates
// instruments. Module-level so both App and the piano-roll sub-components
// (PianoRollTabEditor etc.) allocate the SAME channel for a track.
const trackMidiChannelsRef = { current: {} };
const ensureTrackMidiChannel = (track, tracks) => {
if (!track) return 0;
const trackList = tracks || [];
const inUse = new Set();
trackList.forEach(tr => { if (tr && tr.id !== track.id && tr.midiChannel !== undefined) inUse.add(tr.midiChannel); });
const cached = trackMidiChannelsRef.current[track.id];
if (cached !== undefined && !inUse.has(cached)) return cached;
const preferred = track.midiChannel !== undefined && !inUse.has(track.midiChannel) ? track.midiChannel : null;
if (preferred !== null) { trackMidiChannelsRef.current[track.id] = preferred; return preferred; }
for (let c = 0; c < 16; c++) {
if (c === 9) continue;
if (!inUse.has(c)) { trackMidiChannelsRef.current[track.id] = c; return c; }
}
trackMidiChannelsRef.current[track.id] = 0;
return 0;
};
const assignTrackMidiChannel = (track, tracks) => {
const ch = ensureTrackMidiChannel(track, tracks);
if (track && track.midiChannel !== ch) track.midiChannel = ch;
return ch;
};
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() {
try {
@@ -44,86 +75,117 @@ function makeDistortionCurve(k) {
}
function applyMasteringSettings(s) {
if (!masterBus || !audioCtx) return;
if (!masterBus || !audioCtx || !s) return;
// getAudioContext() invokes this on EVERY call (stopAll, play, VU, ).
// Re-applying identical values in rapid bursts is "fast parameter automation"
// and makes Chromium flag the biquad EQ filters as unstable ("state is bad").
// Only touch the graph when a value actually changed.
const sig = [s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling].join('|');
if (sig === _lastMasteringSig) return;
_lastMasteringSig = sig;
const now = audioCtx.currentTime;
// Clamp every parameter so a stale/incomplete settings object can never push
// NaN or an extreme value into the biquad filters that puts the master
// chain into a bad state and silences ALL audio (the "mt soundfont" symptom).
const clamp = (v, lo, hi) => {
const n = Number(v);
if (!isFinite(n)) return 0; // missing/NaN neutral, never a filter-breaking value
return Math.min(hi, Math.max(lo, n));
};
// 1. EQ Settings
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? s.eqLowGain : 0, now, 0.01);
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid1Gain : 0, now, 0.01);
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid2Gain : 0, now, 0.01);
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? s.eqHighGain : 0, now, 0.01);
// cancelScheduledValues + a slower time constant keeps rapid slider drags
// from piling up automation events on the biquad filters (the trigger for
// Chromium's "BiquadFilterNode: state is bad").
masterBus.eqLowFilter.gain.cancelScheduledValues(now);
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqLowGain, -24, 24) : 0, now, 0.05);
masterBus.eqMid1Filter.gain.cancelScheduledValues(now);
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid1Gain, -24, 24) : 0, now, 0.05);
masterBus.eqMid2Filter.gain.cancelScheduledValues(now);
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqMid2Gain, -24, 24) : 0, now, 0.05);
masterBus.eqHighFilter.gain.cancelScheduledValues(now);
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? clamp(s.eqHighGain, -24, 24) : 0, now, 0.05);
// 2. Imager Settings (Mid/Side matrix width control for each band)
const updateImagerBand = (w, active, gainLL, gainRL, gainLR, gainRR) => {
const widthVal = (s.imagerActive && active) ? w : 0;
const widthVal = (s.imagerActive && active) ? clamp(w, -100, 100) : 0;
const g1 = 1 + widthVal / 200;
const g2 = -widthVal / 200;
gainLL.gain.setTargetAtTime(g1, now, 0.01);
gainRR.gain.setTargetAtTime(g1, now, 0.01);
gainRL.gain.setTargetAtTime(g2, now, 0.01);
gainLR.gain.setTargetAtTime(g2, now, 0.01);
};
updateImagerBand(s.w1, true, masterBus.gainLL1, masterBus.gainRL1, masterBus.gainLR1, masterBus.gainRR1);
updateImagerBand(s.w2, true, masterBus.gainLL2, masterBus.gainRL2, masterBus.gainLR2, masterBus.gainRR2);
updateImagerBand(s.w3, true, masterBus.gainLL3, masterBus.gainRL3, masterBus.gainLR3, masterBus.gainRR3);
updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4);
// 3. Maximizer Settings
const boostLinear = (s.maximizerActive) ? Math.pow(10, s.maxGain / 20) : 1.0;
const boostLinear = (s.maximizerActive) ? Math.pow(10, clamp(s.maxGain, -60, 30) / 20) : 1.0;
masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01);
// Soft Clipper
// Soft Clipper (identity passthrough when off never null curve)
if (s.maximizerActive && s.maxSoftClip > 0) {
const k = 1 + (s.maxSoftClip / 100) * 10;
const k = 1 + (clamp(s.maxSoftClip, 0, 100) / 100) * 10;
masterBus.maximizerSoftClipper.curve = makeDistortionCurve(k);
} else {
masterBus.maximizerSoftClipper.curve = null;
masterBus.maximizerSoftClipper.curve = new Float32Array([-1, 1]);
}
// Upward Compressor
const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, s.maxUpward / 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);
// Limiter Threshold
const ceilingVal = s.maximizerActive ? s.ceiling : -0.1;
const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1;
masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01);
}
function initMasterBus(ctx) {
if (masterBus) return masterBus;
// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad
// with frequency Nyquist gets NaN coefficients "BiquadFilterNode: state
// is bad" the whole mastering chain outputs silence (IN peak yes, OUT no).
// Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break
// the 10 kHz highshelf / 6 kHz imager crossover filters.
const maxFilterFreq = (ctx.sampleRate || 44100) * 0.45;
const clampF = v => Math.max(20, Math.min(v, maxFilterFreq));
// Create EQ filters
const eqLowFilter = ctx.createBiquadFilter();
eqLowFilter.type = 'lowshelf';
eqLowFilter.frequency.value = 100;
eqLowFilter.frequency.value = clampF(100);
const eqMid1Filter = ctx.createBiquadFilter();
eqMid1Filter.type = 'peaking';
eqMid1Filter.frequency.value = 822;
eqMid1Filter.frequency.value = clampF(822);
eqMid1Filter.Q.value = 0.7;
const eqMid2Filter = ctx.createBiquadFilter();
eqMid2Filter.type = 'peaking';
eqMid2Filter.frequency.value = 3200;
eqMid2Filter.frequency.value = clampF(3200);
eqMid2Filter.Q.value = 1.2;
const eqHighFilter = ctx.createBiquadFilter();
eqHighFilter.type = 'highshelf';
eqHighFilter.frequency.value = 10000;
eqHighFilter.frequency.value = clampF(10000);
// Create Stereo Imager nodes
const imagerInput = ctx.createGain();
const imagerOutput = ctx.createGain();
// Imager Crossover Filters
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = 100;
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = 100;
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = 1000;
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = 1000;
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = 6000;
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = 6000;
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = clampF(100);
const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = clampF(100);
const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = clampF(1000);
const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = clampF(1000);
const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = clampF(6000);
const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = clampF(6000);
const split1 = ctx.createChannelSplitter(2);
const split2 = ctx.createChannelSplitter(2);
@@ -181,7 +243,10 @@ function initMasterBus(ctx) {
// Maximizer nodes
const maximizerBoostGain = ctx.createGain();
const maximizerSoftClipper = ctx.createWaveShaper();
maximizerSoftClipper.curve = null;
// NEVER leave the curve null: a WaveShaper with a null/identity curve can
// output silence in some engines, which would kill the whole mastering path.
// Use an explicit linear identity table for passthrough.
maximizerSoftClipper.curve = new Float32Array([-1, 1]);
maximizerSoftClipper.oversample = '4x';
const upwardCompressor = ctx.createDynamicsCompressor();
@@ -282,6 +347,16 @@ function initMasterBus(ctx) {
masterBus.analyser.connect(ctx.destination);
window.masterBus = masterBus;
// Apply mastering once when the chain is first created (and on the
// masteringSettings effect for subsequent changes see useEffect).
if (window.currentMasteringSettings) {
try {
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
applyMasteringSettings(window.currentMasteringSettings);
} catch (e) {
console.warn('initMasterBus apply mastering error:', e);
}
}
return masterBus;
}
@@ -289,22 +364,39 @@ function setMasterVolume(linear) {
if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime);
}
let _lastMasteringActive = null;
let _lastMasteringSig = null;
function toggleMasteringOnMaster(activate, isBypassed) {
if (!masterBus) return;
// Disconnect the dynamic junction
masterBus.inputAnalyser.disconnect();
masterBus.maximizerCompressor.disconnect();
if (activate && !isBypassed) {
// Active routing: inputAnalyser -> EQ -> Imager -> Maximizer -> outputAnalyser
masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser);
masterBus.masteringActive = true;
} else {
// Bypassed routing: inputAnalyser -> outputAnalyser
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
masterBus.masteringActive = false;
const active = !!(activate && !isBypassed);
// Idempotent: don't disconnect/reconnect the mastering chain on every call.
if (_lastMasteringActive === active && masterBus.masteringActive === active) return;
_lastMasteringActive = active;
// Disconnect + immediately reconnect in one synchronous block so the master
// routing can NEVER be left broken (a mid-swap exception would otherwise
// disconnect inputAnalyser and silence ALL audio globally).
try {
masterBus.inputAnalyser.disconnect();
masterBus.maximizerCompressor.disconnect();
if (active) {
masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser);
masterBus.masteringActive = true;
} else {
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
masterBus.masteringActive = false;
}
} catch (e) {
console.warn('toggleMasteringOnMaster error:', e);
// Restore a guaranteed-valid default routing regardless of the failure.
try {
masterBus.inputAnalyser.disconnect();
masterBus.maximizerCompressor.disconnect();
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
masterBus.masteringActive = false;
} catch (e2) {}
}
}
@@ -321,10 +413,12 @@ function getAudioContext() {
if (!masterBus) {
initMasterBus(audioCtx);
}
if (window.currentMasteringSettings) {
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed);
applyMasteringSettings(window.currentMasteringSettings);
}
// The mastering chain is ONLY managed by the masteringSettings effect NOT
// here. getAudioContext runs constantly (play, stopAll, VU, double-click);
// re-toggling/re-automating the biquad EQ here in bursts is "fast parameter
// automation" that makes Chromium flag the filters as unstable
// ("BiquadFilterNode: state is bad") and can leave the master routing broken
// global silence. The React effect applies it once per settings change.
if (window.SonicSF && window.SonicSF.init) {
window.SonicSF.init(audioCtx);
}
@@ -4483,6 +4577,27 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
)
);
};
// Module-level so both ProfileModal (open project) and App (auto-restore) can
// warm the FluidSynth font cache for each track's instrument. This only loads
// the soundfonts; the per-track channel re-selection happens at note time.
const preloadTrackInstruments = async (tracks) => {
if (!window.SonicSF || !window.SonicSF.selectInstrument) return;
const list = tracks || [];
for (let i = 0; i < list.length; i++) {
const t = list[i];
const se = t.synth_engine;
const sfId = (se && se.soundfont_id) || t.soundfont_id;
if (!sfId) continue;
const bank = se ? (se.soundfont_bank !== undefined ? se.soundfont_bank : 0) : (t.soundfont_bank !== undefined ? t.soundfont_bank : 0);
const prog = se ? (se.soundfont_program !== undefined ? se.soundfont_program : 0) : (t.soundfont_program !== undefined ? t.soundfont_program : 0);
const ch = t.midiChannel !== undefined ? t.midiChannel : (i % 16);
try {
await window.SonicSF.selectInstrument(ch, bank, prog, sfId);
} catch (e) {}
}
};
const ProfileModal = ({
isOpen,
onClose,
@@ -4494,7 +4609,14 @@ const ProfileModal = ({
currentProjectId,
setCurrentProjectId,
showToast,
loadAudioBuffersForTracks
loadAudioBuffersForTracks,
setAppWarningModal,
bpm,
setBpm,
setMasteringSettings,
setSessionTabs,
setSubTabs,
trackMidiChannelsRef
}) => {
if (!isOpen) return null;
const [activeTab, setActiveTab] = useState('account');
@@ -4685,6 +4807,8 @@ const ProfileModal = ({
}
setTracks(restoredTracks);
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
trackMidiChannelsRef.current = {};
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
setBpm(restoredBpm.toString());
setSelectedTrackId(restoredTracks[0]?.id || '1');
setProjectName(proj.name);
@@ -6196,7 +6320,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
currentBeat < n.start_beat && newBeat >= n.start_beat
);
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0);
var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0;
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);
});
@@ -6530,7 +6654,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF) {
const ctx = getAudioContext();
var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var clCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(clTrk, activeTracks) : (clTrk ? clTrk.midiChannel : 0);
var clCh = clTrk ? assignTrackMidiChannel(clTrk, activeTracks) : 0;
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null, clCh, clTrk ? clTrk.synth_engine : undefined);
}
}
@@ -6754,7 +6878,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF && window.SonicSF._playNoteFallback) {
const ctx = getAudioContext();
var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var dwCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(dwTrk, activeTracks) : (dwTrk ? dwTrk.midiChannel : 0);
var dwCh = dwTrk ? assignTrackMidiChannel(dwTrk, activeTracks) : 0;
var dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch;
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);
@@ -6850,7 +6974,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF && window.SonicSF._playNoteFallback) {
var pvCtx = getAudioContext();
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0);
var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0;
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);
if (pvNodes) previewNodesRef.current = pvNodes;
@@ -7167,7 +7291,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const renderKeybed = () => {
var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var kbCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(kbTrk, activeTracks) : (kbTrk ? kbTrk.midiChannel : 0);
var kbCh = kbTrk ? assignTrackMidiChannel(kbTrk, activeTracks) : 0;
var kbSynth = kbTrk ? kbTrk.synth_engine : undefined;
const keys = [];
for (let pitch = 127; pitch >= PITCH_START; pitch--) {
@@ -7870,6 +7994,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
soundfont_bank: t.soundfont_bank !== undefined ? t.soundfont_bank : (t.synth_engine ? t.synth_engine.soundfont_bank : null),
soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null),
synth_engine: t.synth_engine || undefined,
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
server_file_id: t.serverFileId || null,
items: items
};
@@ -8427,6 +8552,16 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText');
renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText');
// Safety watchdog: if the mastering chain is broken (signal in, silence
// out e.g. a biquad in a bad state), fall back to the direct routing so
// audio is NEVER globally silent. The user can re-enable mastering after.
const masterInPk = getPeakLevel(masterBus && masterBus.inputAnalyser);
const masterOutPk = getPeakLevel(masterBus && masterBus.outputAnalyser);
if (masterBus && masterBus.masteringActive && masterInPk > 0.01 && masterOutPk < 0.001) {
console.warn('[Mastering] Chain broken (signal in, no signal out) — bypassing mastering to restore audio.');
toggleMasteringOnMaster(false, false);
}
// Wave Observer Oscilloscope Rendering
const woCanvas = woCanvasRef.current;
if (woCanvas) {
@@ -11074,6 +11209,10 @@ const App = () => {
const isSfInstrument = instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_');
const sfBank = bankNumber !== undefined ? bankNumber : (isSfInstrument ? 0 : undefined);
const sfProg = programNumber !== undefined ? programNumber : undefined;
var mt = activeTracksRef.current || tracks;
var curTrk = null;
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } }
var mch = curTrk ? assignTrackMidiChannel(curTrk, mt) : (sfBank === 128 ? 9 : 0);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
const hasInstrument = !!instrumentId;
@@ -11085,10 +11224,6 @@ const App = () => {
soundfont_program: sfProg !== undefined ? sfProg : 0,
soundfont_id: isSfInstrument ? instrumentId.replace('sf_', '') : ''
} : undefined;
var mt = activeTracksRef.current || tracks;
var midx = 0;
for (var mi = 0; mi < mt.length; mi++) { if (mt[mi].id === trackId) { midx = mi; break; } }
var mch = sfBank === 128 ? 9 : (midx % 16);
return { ...t, midiChannel: mch, instrumentId, instrumentProgram: sfProg, instrumentName: displayName, soundfont_bank: sfBank, soundfont_program: sfProg, synth_engine: synthEngine, type: hasInstrument ? 'MIDI' : (t.type === 'MIDI' ? 'audio' : t.type) };
}));
setInstrumentDropdownTrackId(null);
@@ -11097,15 +11232,7 @@ const App = () => {
// Trigger SpessaSynth load + program change when soundfont instrument selected
if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) {
const sfId = instrumentId.replace('sf_', '');
var allTracks = activeTracksRef.current || tracks;
var tidx = 0;
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === trackId) { tidx = i; break; } }
var ch = sfBank === 128 ? 9 : (tidx % 16);
// Store channel for consistent per-track instrument playback
if (!allTracks[tidx] || allTracks[tidx].midiChannel === undefined) {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiChannel: ch } : t));
}
window.SonicSF.selectInstrument(ch, sfBank || 0, sfProg || 0, sfId);
window.SonicSF.selectInstrument(mch, sfBank || 0, sfProg || 0, sfId);
}
setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s;
@@ -11150,11 +11277,15 @@ const App = () => {
setInstrumentDropdownBtnRect(null);
if (instrumentId && instrumentId.startsWith('sf_')) {
// Set instrument on track immediately so Synth button shows the name
var mt2 = activeTracksRef.current || tracks;
var qTrk = null;
for (var qi = 0; qi < mt2.length; qi++) { if (mt2[qi].id === trackId) { qTrk = mt2[qi]; break; } }
var qch = qTrk ? assignTrackMidiChannel(qTrk, mt2) : 0;
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
const sfClean = instrumentId.replace('sf_', '');
const synthEngine = { type: 'soundfont', plugin_id: instrumentId, soundfont_bank: 0, soundfont_program: 0, soundfont_id: sfClean };
return { ...t, instrumentId, instrumentProgram: undefined, instrumentName: displayName, synth_engine: synthEngine };
return { ...t, midiChannel: qch, instrumentId, instrumentProgram: undefined, instrumentName: displayName, synth_engine: synthEngine };
}));
setSelectedSoundFontId(instrumentId);
setSynthCategory('soundfont');
@@ -11279,7 +11410,7 @@ const App = () => {
if (arSubs.length > 0) {
arSubs.forEach(function(as) {
var asTrk = allTracks.find(function(t) { return t.id === as.trackId; });
var asCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(asTrk, allTracks) : (asTrk ? asTrk.midiChannel : 0);
var asCh = asTrk ? assignTrackMidiChannel(asTrk, allTracks) : 0;
var asProg = as.instrumentProgram;
var asSe = as.synth_engine;
if (window.triggerMidiVuActivity) {
@@ -11290,7 +11421,7 @@ const App = () => {
}
// Route to ALL armed tracks (not just the first one)
armedTracks.forEach(function(at) {
var atCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(at, allTracks) : (at.midiChannel !== undefined ? at.midiChannel : 0);
var atCh = assignTrackMidiChannel(at, allTracks);
var atProg = at.instrumentProgram;
var atSe = at.synth_engine;
var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null;
@@ -11311,13 +11442,17 @@ const App = () => {
}
// Stop the note on ALL tracks (not just armed) to prevent stuck notes
// when ARM is toggled off while a key is held
if (window.SonicSF && window.SonicSF.stopNote) {
var stopTracks = activeTracksRef.current || [];
stopTracks.forEach(function(st) {
var stCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(st, stopTracks) : (st.midiChannel !== undefined ? st.midiChannel : 0);
window.SonicSF.stopNote(stCh, pitch);
});
}
if (window.SonicSF && window.SonicSF.stopNote) {
var stopTracks = activeTracksRef.current || [];
stopTracks.forEach(function(st) {
// Only stop channels that actually carry this track's notes
// an index-based fallback could hit another track's dedicated
// channel and kill its sound.
if (!st.synth_engine && st.midiChannel === undefined) return;
var stCh = assignTrackMidiChannel(st, stopTracks);
window.SonicSF.stopNote(stCh, pitch);
});
}
}
// Sustain (CC64), Modulation (CC1), Pitch Bend
@@ -11330,11 +11465,11 @@ const App = () => {
var ccTracks = activeTracksRef.current || [];
var hasArmed = ccTracks.some(function(t) { return t.isArmed; });
if (hasArmed) {
ccTracks.forEach(function(ct) {
if (!ct.isArmed) return;
var ctCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ct, ccTracks) : (ct.midiChannel !== undefined ? ct.midiChannel : 0);
window.SonicSF.controllerChange(ctCh, cc, val);
});
ccTracks.forEach(function(ct) {
if (!ct.isArmed) return;
var ctCh = assignTrackMidiChannel(ct, ccTracks);
window.SonicSF.controllerChange(ctCh, cc, val);
});
} else {
window.SonicSF.controllerChange(midiCh, cc, val);
}
@@ -11348,11 +11483,11 @@ const App = () => {
var pbTracks = activeTracksRef.current || [];
var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; });
if (hasArmedPB) {
pbTracks.forEach(function(pt) {
if (!pt.isArmed) return;
var ptCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pt, pbTracks) : (pt.midiChannel !== undefined ? pt.midiChannel : 0);
window.SonicSF.pitchBend(ptCh, bendVal);
});
pbTracks.forEach(function(pt) {
if (!pt.isArmed) return;
var ptCh = assignTrackMidiChannel(pt, pbTracks);
window.SonicSF.pitchBend(ptCh, bendVal);
});
} else {
window.SonicSF.pitchBend(midiCh, bendVal);
}
@@ -12110,6 +12245,8 @@ const App = () => {
}
setTracks(restoredTracks);
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
trackMidiChannelsRef.current = {};
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
setBpm(restoredBpm.toString());
setSelectedTrackId(restoredTracks[0]?.id || '1');
setProjectName(lastName);
@@ -13411,9 +13548,9 @@ const App = () => {
var sfId = se.soundfont_id;
if (sfId) {
var allTrks = activeTracksRef.current || activeTracks;
var trkIdx = 0;
for (var ti = 0; ti < allTrks.length; ti++) { if (allTrks[ti].id === trackId) { trkIdx = ti; break; } }
var seCh = (se.soundfont_bank === 128 ? 9 : (trkIdx % 16));
// 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);
}
}
@@ -15313,7 +15450,7 @@ const App = () => {
const track = activeTracks.find(t => t.id === trackId);
const destNode = getOrCreateTrackNode(track, context);
const program = track ? track.instrumentProgram : undefined;
var prevCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track ? track.midiChannel : 0);
var prevCh = track ? assignTrackMidiChannel(track, activeTracks) : 0;
window.SonicSF.playNote(
pitch,
velocity,
@@ -15366,7 +15503,7 @@ const App = () => {
// MIDI items playback
const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
var trkCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allPlayTracks) : (track.midiChannel !== undefined ? track.midiChannel : 0);
var trkCh = assignTrackMidiChannel(track, allPlayTracks);
// Ensure instrument is loaded in FluidSynth
if (track.synth_engine && track.synth_engine.type === 'soundfont' && track.synth_engine.soundfont_id) {
window.SonicSF.selectInstrument(trkCh, track.synth_engine.soundfont_bank || 0, track.synth_engine.soundfont_program || 0, track.synth_engine.soundfont_id);
@@ -15506,7 +15643,7 @@ const App = () => {
if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) {
const notePlayEndMain = Math.min(noteEndMain, secEnd);
const program = subTrack.instrumentProgram;
var subCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(subTrack, secTracks) : (subTrack.midiChannel !== undefined ? subTrack.midiChannel : (subIdx % 16));
var subCh = assignTrackMidiChannel(subTrack, secTracks);
if (offsetTime < noteStartMain) {
const delay = noteStartMain - offsetTime;
@@ -15585,6 +15722,7 @@ const App = () => {
// MIDI items playback
const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
var lcCh = assignTrackMidiChannel(track, tracks);
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
midiItems.forEach(item => {
@@ -15605,7 +15743,9 @@ const App = () => {
durationMs,
startTime,
program,
gainNode
gainNode,
lcCh,
track.synth_engine
);
} else {
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
@@ -15615,7 +15755,9 @@ const App = () => {
remainingDurMs,
context.currentTime,
program,
gainNode
gainNode,
lcCh,
track.synth_engine
);
}
}
@@ -15635,16 +15777,14 @@ const App = () => {
const instrumentProgram = track ? track.instrumentProgram : undefined;
const synthEngine = track ? track.synth_engine : undefined;
var allTracks = activeTracksRef.current || [];
var mainCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allTracks) : (track ? track.midiChannel : 0);
// Compute session beat offset from target item
var sessionBeatOffset = 0;
var targetTrk = allTracks.find(function(t) { return t.id === st.trackId; });
if (targetTrk) {
var targetIt = (targetTrk.midiItems || []).find(function(m) { return m.id === st.target_id; });
if (targetIt) sessionBeatOffset = (targetIt.startTime / ((60.0 / bpmVal) * 4)) * 4;
}
var mainCh = track ? assignTrackMidiChannel(track, allTracks) : 0;
// The piano-roll playhead (st.currentTime) and transport are item-relative:
// 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
// future (silence when pressing play). Ghost notes are already relative to
// the item window, so no absolute-session offset is applied anywhere here.
midiNotes.forEach(note => {
const noteOnBeat = (note.start_beat || 0) + sessionBeatOffset;
const noteOnBeat = note.start_beat || 0;
const noteDurBeat = note.duration_beats || 1;
const noteStartSec = noteOnBeat * secondsPerBeat;
const noteDurSec = noteDurBeat * secondsPerBeat;
@@ -15666,16 +15806,9 @@ const App = () => {
var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined);
if (ghostProg === undefined && !ghostSynth) return;
var ghostDest = getOrCreateTrackNode(ghostTrack, context);
var ghostCh = 0;
if (ghostTrack) {
if (ghostTrack.midiChannel !== undefined) {
ghostCh = ghostTrack.midiChannel;
} else {
ghostCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ghostTrack, allTracks) : (ghostTrack ? ghostTrack.midiChannel : 0);
}
}
var ghostCh = ghostTrack ? assignTrackMidiChannel(ghostTrack, allTracks) : 0;
layer.notes.forEach(function(note) {
var beat = (note.start_beat || 0) + sessionBeatOffset;
var beat = note.start_beat || 0;
var dur = note.duration_beats || 1;
var startSec = beat * secondsPerBeat;
var durSec = dur * secondsPerBeat;
@@ -23767,7 +23900,14 @@ const App = () => {
currentProjectId: currentProjectId,
setCurrentProjectId: setCurrentProjectId,
showToast: showToast,
loadAudioBuffersForTracks: loadAudioBuffersForTracks
loadAudioBuffersForTracks: loadAudioBuffersForTracks,
setAppWarningModal: setAppWarningModal,
bpm: bpm,
setBpm: setBpm,
setMasteringSettings: setMasteringSettings,
setSessionTabs: setSessionTabs,
setSubTabs: setSubTabs,
trackMidiChannelsRef: trackMidiChannelsRef
}), /*#__PURE__*/React.createElement(SaveProjectModal, {
isOpen: saveProjectModalOpen,
onClose: () => setSaveProjectModalOpen(false),
File diff suppressed because one or more lines are too long
+75 -18
View File
@@ -23,6 +23,8 @@
let _activeOscillators = {};
let _gainNode = null;
let _scheduledNotes = [];
let _loadPromises = {};
let _sfloadSeq = 0;
const getCtx = function () {
if (_audioCtx) {
@@ -84,14 +86,21 @@
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
// ── Renderer selection ──
// ScriptProcessor is the default and FINAL choice: it is
// pull-based (onaudioprocess is invoked by the audio thread),
// so it cannot starve when the main thread is busy (font
// loading, WASM decode, UI). The AudioWorklet push model
// (setInterval on the main thread) starves under load and
// produces SILENCE on this machine — repeatedly confirmed.
// The deprecation console warning is purely cosmetic.
var _useScriptNode = true;
try {
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
console.log("[SonicSF] Worklet registered OK");
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
console.log("[SonicSF] Worklet registered OK (unused)");
} catch (e) {
console.warn("[SonicSF] Worklet reg failed:", e);
}
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
var TOTAL_MEMORY = 256 * 1024 * 1024;
@@ -104,6 +113,11 @@
},
TOTAL_MEMORY: TOTAL_MEMORY,
printErr: function (msg) {
// "No preset found on channel" is FluidSynth's
// expected notice when a soundfont simply has no
// preset for a bank (e.g. bank 128 on a melodic-only
// font) — the note is just silent, not an error.
if (msg && msg.indexOf('No preset found on channel') !== -1) return;
console.warn('[FluidSynth:err]', msg);
}
});
@@ -132,7 +146,15 @@
if (!_useScriptNode) {
try {
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
// Force stereo output regardless of the device's
// channel count — FluidSynth renders stereo, and a
// mono output would crash the worklet (out[1] undefined).
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge', {
numberOfOutputs: 1,
outputChannelCount: [2],
channelCount: 2,
channelCountMode: 'explicit'
});
_workletNode.connect(_gainNode);
console.log("[SonicSF] AudioWorklet node connected via gain");
_startRenderLoop();
@@ -186,11 +208,16 @@
},
_tryLoadSFL: function (buf, ext) {
var fname = '/' + ext + '_' + Date.now();
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
try { _fluidModule.FS.unlink(fname); } catch (e) {}
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
var cPath = this._allocCStr(fname);
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 1);
// reset_presets = 0: loading a NEW soundfont must NOT reset the
// presets already selected on other channels. With 1, FluidSynth
// re-points every channel to the new font's preset 0, so loading a
// second instrument silently changes the first one's sound
// (decay/loop envelope…).
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 0);
_fluidModule._free(cPath);
try { _fluidModule.FS.unlink(fname); } catch (e) {}
return handle;
@@ -203,6 +230,24 @@
_currentSfId = sfId;
return true;
}
// Deduplicate concurrent loads: rapid key presses (or several armed
// tracks) all call loadSoundFont for the same font before the first
// load resolves. Without this, the same soundfont is sfload'd several
// times (handles 1,2,3,4…) — wasting the 256MB WASM heap and stalling
// notes until each load finishes (audible lag, then silence).
if (!_loadPromises[sfId]) {
_loadPromises[sfId] = this._doLoadSoundFont(sfId).then(function (ok) {
// Do NOT cache failures: a transient error (network hiccup,
// memory pressure) must not permanently kill the instrument —
// the next note retries the load and recovers.
if (!ok) delete _loadPromises[sfId];
return ok;
});
}
return _loadPromises[sfId];
},
_doLoadSoundFont: async function (sfId) {
try {
var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null;
@@ -258,16 +303,16 @@
if (!ok) return;
}
var engKey = (sfId || '') + ':' + bank + ':' + program;
if (!_engineChMap[engKey]) {
if (channel === undefined || channel === null) {
if (channel === undefined || channel === null) {
if (!_engineChMap[engKey]) {
var allocCh = this.allocateChannel(bank);
_engineChMap[engKey] = allocCh;
channel = allocCh;
} else {
_engineChMap[engKey] = channel;
channel = _engineChMap[engKey];
}
} else {
channel = _engineChMap[engKey];
} else if (!_engineChMap[engKey]) {
_engineChMap[engKey] = channel;
}
var sfHandle = _sfHandleMap.get(sfId);
if (sfHandle !== undefined) {
@@ -423,18 +468,30 @@
var finalBank = usedBank;
var finalProg = usedProg;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
if (_channels[ch] && _channels[ch].program !== undefined) {
finalBank = _channels[ch].bank;
finalProg = _channels[ch].program;
if (_channels[ch].sfId !== undefined) {
finalSfId = _channels[ch].sfId;
}
var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is
// authoritative. Channel state is only a cache: it must never
// mask the track's instrument, otherwise multi-track ARM or a
// re-picked instrument plays the wrong soundfont. Without an
// engine, fall back to the soundfont configured on the channel.
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
finalBank = cachedCh.bank;
finalProg = cachedCh.program;
finalSfId = cachedCh.sfId;
}
// Ensure the soundfont is actually loaded before the note plays.
// Quick instrument pick on a track does not pre-load it, so load
// lazily here and retry the note once the font is ready.
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
self.loadSoundFont(finalSfId).then(function (ok) {
if (ok) doNote();
});
return;
}
// Program change at note time, not call time — ensures correct
// instrument for each item regardless of processing order.
// Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches).
var cachedCh = _channels[ch];
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
+11 -4
View File
@@ -15,17 +15,24 @@ class FluidSynthBridge extends AudioWorkletProcessor {
process(inputs, outputs) {
const out = outputs[0];
if (!out) return true;
if (!out || out.length === 0) return true;
this.called++;
const numCh = out.length;
const len = out[0].length;
const qL = this.leftQ;
const qR = this.rightQ;
let fi = 0;
let si = 0;
// Handle any output channel count (mono devices produce 1 channel, so
// out[1] may be undefined — never write into a missing channel).
for (let i = 0; i < len; i++) {
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
out[0][i] = qL[fi][si];
out[1][i] = qR[fi][si];
if (fi >= qL.length) {
for (let c = 0; c < numCh; c++) out[c][i] = 0;
continue;
}
for (let c = 0; c < numCh; c++) {
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
}
si++;
if (si >= qL[fi].length) { fi++; si = 0; }
}
+2 -2
View File
@@ -16,7 +16,7 @@
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202607311050"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></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/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608022001" defer></script>
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+80
View File
@@ -1194,3 +1194,83 @@
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.
### [2026-08-03 10:15] Task: Fix MIDI ARM không phát âm thanh sau khi load instrument
- **Tóm tắt thay đổi:** Sửa `soundfontPlayer.js` để phím MIDI trên track ARM phát đúng instrument: (1) `_playNoteFluid` không còn để state mặc định của channel (bank 0, program 0, không sfId) che mất `synth_engine` của track → `finalSfId` luôn đúng; (2) tự load soundfont lười (lazy) ngay trong đường phát note nếu font chưa vào `_sfHandleMap` (track pick nhanh từ dropdown không gọi `selectInstrument` → trước đây rơi vào `bank_select`/`program_change` trên synth không có soundfont → câm lặng); (3) `selectInstrument` không còn đổi hướng channel khi đã truyền channel tường minh (sửa lỗi 2 track dùng chung instrument bị giật channel). Bump version cache `soundfontPlayer.js` trong `index.html`.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test logic bằng harness `node /tmp/kilo/test_sonicsf_fix.js` (mô phỏng FluidSynth): S1 lazy-load + program_select + noteon, S2 channel cấu hình sẵn không sfload lại, S3 program-only fallback, S4 no-instrument vẫn silent, S5 2 track cùng instrument giữ channel riêng — ALL PASSED. Cần hard-refresh trình duyệt để nạp `soundfontPlayer.js` bản mới.
### [2026-08-03 10:30] Task: Fix ARM nhiều track - instrument sai theo track (channel state che mất synth_engine)
- **Tóm tắt thay đổi:** `_playNoteFluid` trước đây ưu tiên state channel (`_channels[ch].sfId`) làm nguồn instrument cho note → khi ARM nhiều track hoặc track đổi instrument qua dropdown nhanh, note bị phát theo instrument cũ/khác đang "dính" trên channel (leftover state hoặc 2 track trùng channel) → sai instrument từng track. Sửa: `synth_engine` của track là nguồn quyết định; channel state chỉ là cache (chỉ dùng khi note không có engine). Khi note mang engine khác với channel đang giữ, tự động `program_select` lại đúng instrument trước `noteon`; vẫn lazy-load soundfont nếu chưa nạp. Bump version cache `soundfontPlayer.js` trong `index.html`.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_multi_track.js` (mô phỏng routing ARM như app.jsx): (A) 3 track 3 instrument distinct channel → noteon đúng, không re-select thừa; (B) 3 track quick-pick (không midiChannel, channel đang giữ instrument cũ) → mỗi track re-select đúng Q1/Q2/Q3 trước noteon; (C) 2 track trùng channel 0 → track 2 re-select program 40 trước noteon. Harness `test_sonicsf_fix.js` (single-track S1-S5) vẫn ALL PASSED. Hard refresh trình duyệt để nạp bản mới.
### [2026-08-03 10:40] Task: Fix lag khi ARM sau reload + hết âm thanh sau vài lần nhấn MIDI
- **Tóm tắt thay đổi:** (1) **Dedup load soundfont**: `loadSoundFont` trước đây không chặn các lệnh gọi đồng thời — nhấn phím MIDI liên tục (hoặc nhiều track ARM) kích hoạt lazy-load cùng lúc → SGM-V2.01 bị `sfload` 4 lần (handle 1,2,3,4 trong log), mỗi bản ~52MB trong heap WASM 256MB → cạn bộ nhớ, các lần load sau fail → câm sau vài lần nhấn + mỗi note chờ load (lag). Thêm `_loadPromises[sfId]` dedup (1 font = 1 lần load, cache cả kết quả fail) + `_sfloadSeq` cho filename temp duy nhất tránh trùng tên khi load 2 font khác nhau đồng thời. (2) **Preload instrument khi mở/khôi phục dự án**: thêm `preloadTrackInstruments(tracks)` gọi `selectInstrument` cho từng track có soundfont ngay sau khi `setTracks` trong `handleOpenProject``restoreLastSessionProject` → nhấn MIDI key đầu tiên không còn lag (font đã nạp sẵn).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `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` (babel) OK, bundle chứa `preloadTrackInstruments`. Harness `node /tmp/kilo/test_dedup.js`: 8 lần `loadSoundFont` cùng 1 font đồng thời → chỉ 1 `sfload`, 1 lần đọc cache; load lại font đã nạp → 0 sfload; 2 font khác nhau đồng thời → đúng 2 sfload — ALL PASSED. `test_sonicsf_fix.js` + `test_multi_track.js` vẫn ALL PASSED. Hard refresh trình duyệt.
### [2026-08-03 11:00] Task: ARM multitrack - mỗi track dùng channel MIDI riêng, không làm đổi instrument track khác
- **Tóm tắt thay đổi:** FluidSynth có 16 channel; nếu 2 track trùng channel thì ARM track này sẽ `program_select` đè instrument của track kia. Thêm cơ chế channel riêng cho từng track trong app.jsx: `trackMidiChannelsRef` + `ensureTrackMidiChannel`/`assignTrackMidiChannel` cấp channel ổn định, duy nhất (0-15, bỏ qua 9 - slot percussion cổ điển), tự sửa khi 2 track trùng channel được lưu. Áp dụng ở mọi nơi tính channel: `setTrackInstrumentWithProgram`, `setTrackInstrument` (dropdown nhanh), `preloadTrackInstruments`, routing ARM note-on (armedTracks + piano-roll sub-tab), note-off/CC/PitchBend (bỏ fallback `index % 16` — tránh dừng nhầm note của track khác). Lưu `midi_channel` khi save project + restore; reset map channel khi mở/khôi phục dự án. Kết hợp doNote cũ (synth_engine quyết định + re-select trước noteon) → mỗi track luôn phát đúng instrument của nó dù ARM nhiều track, chord/hợp âm đồng thời không còn đổi âm sắc nhau.
- **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 `assignTrackMidiChannel` (8 chỗ). Harness `node /tmp/kilo/test_channels.js`: 3 track → channel 0,1,2 duy nhất; gọi lại ổn định; track mới lấy channel trống; track lưu trùng channel được cấp lại; track lưu channel duy nhất được giữ; percussion giữ 9 + track trống khác lấy channel riêng — ALL PASSED. 3 harness cũ (single/multi/dedup) vẫn PASS. Hard refresh.
### [2026-08-03 11:30] Task: Fix instrument sau làm đổi thông số instrument trước + ReferenceError preloadTrackInstruments + warning channel 9
- **Tóm tắt thay đổi:** (1) **sfload reset_presets 1→0** trong `soundfontPlayer._tryLoadSFL`: trước đây mỗi lần load soundfont mới, FluidSynth reset preset của MỌI channel về preset 0 của font mới → load instrument sau làm đổi âm sắc (decay/loop...) của instrument trước dù channel đã `program_select` riêng; giờ channel giữ nguyên instrument, doNote/selectInstrument tự `program_select` rõ ràng. (2) **ReferenceError `preloadTrackInstruments`**: hàm bị đặt nhầm bên trong `ProfileModal` (không cùng scope với `restoreLastSessionProject` trong `App`) → lỗi khi reload. Chuyển `preloadTrackInstruments` lên module-level (self-contained, chỉ warm font cache). Đồng thời sửa lỗi tiềm ẩn của `handleOpenProject` trong ProfileModal: truyền thêm props App-scoped (`setAppWarningModal`, `bpm`, `setBpm`, `setMasteringSettings`, `setSessionTabs`, `setSubTabs`, `trackMidiChannelsRef`) — trước đây click mở dự án trong modal sẽ ReferenceError. (3) **"No preset found on channel 9 [bank=128 prog=0]"**: lọc warning benign trong `printErr` (font không có preset bank 128 thì note chỉ câm, không phải lỗi) + reset=0 giảm nguồn sinh ra nó.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_reset.js`: sfload reset=0, load B không re-select channel A, noteon ch0/ch1 không kèm program_select thừa — PASSED. 5 harness còn lại (single/multi/dedup/channels) vẫn PASS. Deprecation ScriptProcessorNode giữ nguyên (đổi sang AudioWorklet có rủi ro crackle, cần test audio thật). Hard refresh.
### [2026-08-03 11:50] Task: Play MIDI item luôn dùng instrument của track chứa item (kể cả item duplicate)
- **Tóm tắt thay đổi:** Rà soát mọi đường phát MIDI trong app.jsx để chắc chắn playNote luôn nhận channel riêng của track (`assignTrackMidiChannel`) + `synth_engine` của track đó: (1) **fix bug `startLocalTrackPlayback`** (local selection loop) — trước đây gọi `playNote` KHÔNG truyền channel/synth_engine → rơi về channel 0, phát nhầm instrument của track đang giữ channel 0; giờ truyền `lcCh` + `track.synth_engine`. (2) Đồng nhất các đường còn lại (transport `startTrackPlayback`, section sub-track, `schedulePianoRollMidi`, `playMidiPreviewNote`, piano-roll canvas/click/keybed/brush preview, ghost layers) từ `getTrackMidiChannel``assignTrackMidiChannel` để mọi path dùng đúng dedicated channel của track. Vì playback lấy `synth_engine`/`instrumentProgram` từ track CHỨA item (item không lưu instrument, duplicate chỉ copy notes) nên khi duplicate/move MIDI item sang track khác, item sẽ tự động phát instrument đã load ở track đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `getTrackMidiChannel` không còn trong app.jsx (0), `assignTrackMidiChannel` có 19 chỗ. Bundle chứa `lcCh=assignTrackMidiChannel(track,tracks)` + playNote truyền `lcCh,track.synth_engine`. 5 harness (single/multi/dedup/channels/reset) vẫn PASS. Hard refresh.
### [2026-08-03 12:10] Task: Fix ReferenceError assignTrackMidiChannel + bật AudioWorklet thay ScriptProcessor
- **Tóm tắt thay đổi:** (1) **ReferenceError `assignTrackMidiChannel is not defined`**: allocator channel (trackMidiChannelsRef/ensureTrackMidiChannel/assignTrackMidiChannel) đặt bên trong `App`, nhưng các sub-component piano roll (`PianoRollTabEditor` — canvas preview, click note, draw/paint brush, keybed) là module-level → không thấy hàm → lỗi khi render/play. Chuyển allocator lên **module-level** (đầu file, trước PianoRollTabEditor), App dùng chung bản đó (bỏ khai báo `useRef` cục bộ trong App; reset `trackMidiChannelsRef.current = {}` khi load dự án vẫn dùng chung object). (2) **Deprecation ScriptProcessorNode**: bật **AudioWorklet** `fluidsynth-bridge` làm mặc định (`_useScriptNode = false`), chỉ fallback ScriptProcessor khi `new AudioWorkletNode`/`addModule` thất bại → hết warning deprecation; `_startRenderLoop` (queue 16 block × 512 sample ~186ms headroom, interval 2× frame rate) vốn đã được thiết kế cho worklet.
- **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`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, bundle load smoke test (stub React/ReactDOM/window) OK. Harness `test_sonicsf_fix.js` với mock AudioWorkletNode + performance: `workletCreated=1 scriptProcCreated=0` (worklet được dùng), S1-S5 vẫn PASS; trước đó (thiếu mock performance) xác nhận fallback ScriptProcessor hoạt động. 4 harness còn lại vẫn PASS. Hard refresh.
### [2026-08-03 12:30] Task: Fix crash AudioWorklet - "Cannot set properties of undefined (setting '0')" khi output mono
- **Tóm tắt thay đổi:** `FluidSynthBridge.process` luôn ghi stereo `out[0]`/`out[1]`; khi thiết bị/context xuất mono (chỉ 1 channel), `out[1]``undefined``out[1][i] = ...` ném TypeError, dừng audio. Fix: (1) `fluidsynth-bridge.js` xử lý mọi số channel output (vòng lặp theo `out.length`, chẵn=lấy L, lẻ=lấy R, trường hợp cạn queue ghi 0 cho từng channel); (2) `soundfontPlayer.js` tạo `AudioWorkletNode` với `outputChannelCount: [2]`, `channelCount: 2`, `channelCountMode: 'explicit'` để ép stereo (FluidSynth render stereo) thay vì để device quyết định.
- **Các file ảnh hưởng:** `app/static/js/worklets/fluidsynth-bridge.js`, `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_worklet.js` mô phỏng process(): (1) output mono 1 channel → mirror left, không crash; (2) stereo → L/R đúng; (3) queue cạn → silence. Tất cả PASS. 5 harness khác vẫn PASS. Worklet + soundfontPlayer `node --check` OK. Không cần build bundle (2 file served trực tiếp). Hard refresh.
### [2026-08-03 12:45] Task: Fix không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định
- **Tóm tắt thay đổi:** Sau khi bật AudioWorklet làm mặc định, audio không phát khi play MIDI item trên track (môi trường user bị 2 lỗi liên tiếp: crash mono rồi câm). ScriptProcessor là đường đã được xác nhận hoạt động ổn định (mọi test trước đó). Quay lại `_useScriptNode = true` (ScriptProcessor mặc định) để đảm bảo âm thanh; giữ code AudioWorklet (đã mono-safe) + thêm cache-busting `?v=202608031240` cho URL `addModule` để khi bật lại không bị cache bản cũ. Deprecation warning quay lại nhưng chỉ là cảnh báo cosmetic.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_render.js` (path ScriptProcessor): workletCreated=0, ScriptProcessor được tạo, playNote → noteon, onaudioprocess render ra audio peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion ScriptProcessor mặc định → rc=0. 6 harness còn lại PASS (test_worklet rc báo 1 do glitch shell nhưng result file ghi ALL TESTS PASSED). Không cần build bundle (file served trực tiếp). Hard refresh.
### [2026-08-03 13:00] Task: Fix piano roll tab không phát âm thanh instrument của track
- **Tóm tắt thay đổi:** `schedulePianoRollMidi` cộng `sessionBeatOffset` (vị trí tuyệt đối của item trong project, đơn vị beat) vào `start_beat` của mọi note → note được lên lịch ở **thời điểm tuyệt đối** của project, trong khi playhead/tab piano roll là **item-relative** (0 = đầu item). Item đặt sau vị trí 0 (vd 10s) → nhấn play nghe không có âm thanh trong 10s (note được schedule ở now+10s), sau đó mới kêu → cảm giác "không có âm thanh instrument". Fix: bỏ `sessionBeatOffset` ở cả loop note chính và loop ghost (ghost `relative_start_beat` vốn đã item-relative) — note phát theo vị trí tương đối item, khớp playhead; item ở vị trí 0 không đổi hành vi. `sessionSyncMode` chỉ là chế độ hiển thị ghost/context, không liên quan timing phát.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, hết `sessionBeatOffset` (0 chỗ). Harness `node /tmp/kilo/test_pr_timing.js` mô phỏng timing mới: item ở project 10s, nhấn play mới → note đầu phát ngay +0s (trước đây +10s), note 2 ở +2s; resume 1.2s → bỏ note đầu, schedule note sau; item vị trí 0 không đổi — ALL PASSED. 6 harness còn lại PASS. Hard refresh.
### [2026-08-03 13:15] Task: Fix mất soundfont khi ARM + BiquadFilterNode state is bad
- **Tóm tắt thay đổi:** (1) **Master chain bị NaN**: `applyMasteringSettings` chạy mỗi lần `getAudioContext()`; nếu `window.currentMasteringSettings` bị thiếu field/NaN (vd project lưu cũ, slider kéo cực hạn) thì `eqLowFilter.gain.setTargetAtTime(undefined/NaN)` → biquad "state is bad" → master chain im lặng → mọi audio (kể cả MIDI/ARM) biến mất ("mất soundfont"). Thêm `clamp()` chặn mọi tham số EQ (±24dB), imager (±100), maximizer, ceiling — NaN/thiếu → 0 (trung tính), cực hạn → giới hạn an toàn. (2) `toggleMasteringOnMaster` trở nên idempotent (chỉ disconnect/reconnect khi trạng thái đổi) — trước đây gọi lại liên tục từ `getAudioContext()` gây reconnect nhanh → biquad mất ổn định. (3) **loadSoundFont không cache lỗi vĩnh viễn**: lỗi tải thoáng qua (mạng 503, áp lực bộ nhớ) trước đây bị giữ trong `_loadPromises` → instrument câm vĩnh viễn tới khi reload; giờ xóa cache lỗi để note kế tiếp thử lại và tự hồi phục.
- **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`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_retry.js`: clamp undefined/NaN → 0, cực hạn → giới hạn; load font lỗi 503 lần 1 → lần 2 retry thành công (2 fetches) → note phát được — ALL PASSED. 8 harness còn lại PASS. Deprecation ScriptProcessorNode vẫn còn (giữ ScriptProcessor vì ổn định, worklet từng gây lỗi thiết bị). Hard refresh.
### [2026-08-03 13:30] Task: Hết ScriptProcessor deprecation (bật AudioWorklet) + hết BiquadFilterNode state is bad (signature guard)
- **Tóm tắt thay đổi:** (1) **Deprecation**: bật lại AudioWorklet làm renderer mặc định (`_useScriptNode = false`) — worklet giờ đã mono-safe + ép stereo `outputChannelCount:[2]` + URL `addModule` có cache-busting `?v=` nên luôn nạp bản đã sửa; fallback ScriptProcessor khi tạo worklet thất bại. Trước đây "mất tiếng khi transport" là do worklet cũ (pre-mono-fix) bị cache. (2) **BiquadFilterNode: state is bad**: `applyMasteringSettings` được gọi mỗi lần `getAudioContext()` (stopAll/play/VU…), liên tục `setTargetAtTime` vào EQ biquad = "fast parameter automation" → Chromium báo state bad dù giá trị hợp lệ. Thêm **signature guard**: chỉ áp dụng khi giá trị thực sự đổi → bỏ chùm automation lặp, giữ clamp chống NaN. `toggleMasteringOnMaster` vẫn idempotent.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `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. Harness `test_render.js` (worklet mặc định): workletCreated=1 scriptProcCreated=0, noteon=1, 26 PCM frames posted, peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion worklet → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — lần đầu sẽ tải worklet mới. Nếu nghe crackle/mất tiếng khi phát, báo lại để quay lại ScriptProcessor.
### [2026-08-03 13:40] Task: Fix Lỗi không có âm thanh - quay lại ScriptProcessor (quyết định cuối)
- **Tóm tắt thay đổi:** Bật AudioWorklet lần 2 vẫn làm mất tiếng trên máy user. Nguyên nhân thực sự: worklet là push model (`setInterval` trên main thread) — khi main thread bận (load font/WASM decode/UI), vòng lặp nghẽn → worklet hết frame → câm. ScriptProcessor là pull-based (`onaudioprocess` do audio thread gọi) nên không bao giờ đói dữ liệu. **Quyết định cuối: giữ ScriptProcessor làm renderer mặc định vĩnh viễn** (`_useScriptNode = true`); warning deprecation chỉ cosmetic. Worklet (đã mono-safe + stereo-forced + cache-busting) giữ lại như opt-in để test sau.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Harness `test_render.js` (ScriptProcessor mặc định): workletCreated=0, ScriptProcessor được tạo, noteon=1, render peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` assertion ScriptProcessor → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — âm thanh hoạt động lại.
### [2026-08-03 13:45] Task: Fix mất âm thanh khi nhấp đôi MIDI item vào Piano Roll
- **Tóm tắt thay đổi:** `handleEditMidiInTab` (nhấp đôi MIDI item → mở PIANO ROLL tab) preload instrument bằng channel cũ `trkIdx % 16`, trong khi MỌI đường phát (schedulePianoRollMidi, transport, ARM) dùng dedicated channel (`assignTrackMidiChannel`). Channel lệch nhau → tab-open `selectInstrument` cấu hình nhầm channel (có thể đè instrument của track khác nếu trùng channel 0-15), khiến âm thanh trong/pianô sau khi mở tab không còn đúng. Sửa: dùng `assignTrackMidiChannel(track, allTrks)` cho preload khi mở tab → khớp đúng channel phát.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_dbl.js` mô phỏng toàn bộ flow double-click (init → tab-open selectInstrument → schedulePianoRollMidi playNote → ScriptProcessor render): noteon đúng channel, render peak 0.25 (không câm) — ALL PASSED. 9 harness còn lại PASS. Nếu vẫn mất âm thanh sau hard refresh, cần xác định rõ: câm toàn cục hay chỉ play trong piano roll, và có console error gì.
### [2026-08-03 14:00] Task: Fix câm toàn cục sau nhấp đôi MIDI item + BiquadFilterNode state is bad (triệt để)
- **Tóm tắt thay đổi:** User xác nhận: sau nhấp đôi MIDI item vào Piano Roll, câm TOÀN CỤC (main cũng hết tiếng) + lỗi `BiquadFilterNode: state is bad` vẫn còn. Nguyên nhân: master chain (EQ/imager biquad) bị `toggleMasteringOnMaster` + `applyMasteringSettings` gọi lại **mỗi lần** `getAudioContext()` (play, stopAll, double-click, VU...) — burst "fast parameter automation" vào biquad làm Chromium báo state bad, và nếu routing master bị ngắt giữa chừng → câm toàn cục. Fix triệt để: (1) **gỡ mastering khỏi getAudioContext** — master chain chỉ được áp dụng 1 lần khi `initMasterBus` tạo (nếu có `currentMasteringSettings`) + qua React effect khi settings đổi; (2) **time constant 0.01 → 0.05** + `cancelScheduledValues` trước mỗi `setTargetAtTime` EQ — hết tích tụ automation event khi kéo slider; (3) `toggleMasteringOnMaster` bọc try/catch, **luôn reconnect lại routing hợp lệ** dù lỗi — không bao giờ để inputAnalyser bị ngắt không nối (gây câm toàn cục).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `toggleMasteringOnMaster` còn 3 chỗ (def + initMasterBus + effect). 9 harness (single/multi/dedup/reset/channels/render/pr_timing/retry/dbl) vẫn PASS. Hard refresh (Ctrl+F5). Nếu lỗi biquad vẫn hiện khi bật MasteringModal và kéo EQ, đó có thể là false-positive Chromium (parallel biquad) — báo tôi để tôi đổi cấu trúc imager/EQ.
### [2026-08-03 14:15] Task: Fix câm toàn cục - IN peak có, OUT peak không (master chain chết)
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.