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
+245 -105
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_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; 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) // Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() { (function handleSfsDeepLink() {
try { try {
@@ -44,18 +75,41 @@ function makeDistortionCurve(k) {
} }
function applyMasteringSettings(s) { 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; 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 // 1. EQ Settings
masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? s.eqLowGain : 0, now, 0.01); // cancelScheduledValues + a slower time constant keeps rapid slider drags
masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid1Gain : 0, now, 0.01); // from piling up automation events on the biquad filters (the trigger for
masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid2Gain : 0, now, 0.01); // Chromium's "BiquadFilterNode: state is bad").
masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? s.eqHighGain : 0, now, 0.01); 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) // 2. Imager Settings (Mid/Side matrix width control for each band)
const updateImagerBand = (w, active, gainLL, gainRL, gainLR, gainRR) => { 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 g1 = 1 + widthVal / 200;
const g2 = -widthVal / 200; const g2 = -widthVal / 200;
@@ -71,59 +125,67 @@ function applyMasteringSettings(s) {
updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4); updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4);
// 3. Maximizer Settings // 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); 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) { 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); masterBus.maximizerSoftClipper.curve = makeDistortionCurve(k);
} else { } else {
masterBus.maximizerSoftClipper.curve = null; masterBus.maximizerSoftClipper.curve = new Float32Array([-1, 1]);
} }
// Upward Compressor // 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); masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear, now, 0.01);
// Limiter Threshold // 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); masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01);
} }
function initMasterBus(ctx) { function initMasterBus(ctx) {
if (masterBus) return masterBus; 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 // Create EQ filters
const eqLowFilter = ctx.createBiquadFilter(); const eqLowFilter = ctx.createBiquadFilter();
eqLowFilter.type = 'lowshelf'; eqLowFilter.type = 'lowshelf';
eqLowFilter.frequency.value = 100; eqLowFilter.frequency.value = clampF(100);
const eqMid1Filter = ctx.createBiquadFilter(); const eqMid1Filter = ctx.createBiquadFilter();
eqMid1Filter.type = 'peaking'; eqMid1Filter.type = 'peaking';
eqMid1Filter.frequency.value = 822; eqMid1Filter.frequency.value = clampF(822);
eqMid1Filter.Q.value = 0.7; eqMid1Filter.Q.value = 0.7;
const eqMid2Filter = ctx.createBiquadFilter(); const eqMid2Filter = ctx.createBiquadFilter();
eqMid2Filter.type = 'peaking'; eqMid2Filter.type = 'peaking';
eqMid2Filter.frequency.value = 3200; eqMid2Filter.frequency.value = clampF(3200);
eqMid2Filter.Q.value = 1.2; eqMid2Filter.Q.value = 1.2;
const eqHighFilter = ctx.createBiquadFilter(); const eqHighFilter = ctx.createBiquadFilter();
eqHighFilter.type = 'highshelf'; eqHighFilter.type = 'highshelf';
eqHighFilter.frequency.value = 10000; eqHighFilter.frequency.value = clampF(10000);
// Create Stereo Imager nodes // Create Stereo Imager nodes
const imagerInput = ctx.createGain(); const imagerInput = ctx.createGain();
const imagerOutput = ctx.createGain(); const imagerOutput = ctx.createGain();
// Imager Crossover Filters // Imager Crossover Filters
const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = 100; 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 = 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 = 1000; 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 = 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 = 6000; 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 = 6000; const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = clampF(6000);
const split1 = ctx.createChannelSplitter(2); const split1 = ctx.createChannelSplitter(2);
const split2 = ctx.createChannelSplitter(2); const split2 = ctx.createChannelSplitter(2);
@@ -181,7 +243,10 @@ function initMasterBus(ctx) {
// Maximizer nodes // Maximizer nodes
const maximizerBoostGain = ctx.createGain(); const maximizerBoostGain = ctx.createGain();
const maximizerSoftClipper = ctx.createWaveShaper(); 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'; maximizerSoftClipper.oversample = '4x';
const upwardCompressor = ctx.createDynamicsCompressor(); const upwardCompressor = ctx.createDynamicsCompressor();
@@ -282,6 +347,16 @@ function initMasterBus(ctx) {
masterBus.analyser.connect(ctx.destination); masterBus.analyser.connect(ctx.destination);
window.masterBus = masterBus; 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; return masterBus;
} }
@@ -289,22 +364,39 @@ function setMasterVolume(linear) {
if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime); if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime);
} }
let _lastMasteringActive = null;
let _lastMasteringSig = null;
function toggleMasteringOnMaster(activate, isBypassed) { function toggleMasteringOnMaster(activate, isBypassed) {
if (!masterBus) return; if (!masterBus) return;
// Disconnect the dynamic junction const active = !!(activate && !isBypassed);
masterBus.inputAnalyser.disconnect(); // Idempotent: don't disconnect/reconnect the mastering chain on every call.
masterBus.maximizerCompressor.disconnect(); if (_lastMasteringActive === active && masterBus.masteringActive === active) return;
_lastMasteringActive = active;
if (activate && !isBypassed) { // Disconnect + immediately reconnect in one synchronous block so the master
// Active routing: inputAnalyser -> EQ -> Imager -> Maximizer -> outputAnalyser // routing can NEVER be left broken (a mid-swap exception would otherwise
masterBus.inputAnalyser.connect(masterBus.eqLowFilter); // disconnect inputAnalyser and silence ALL audio globally).
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser); try {
masterBus.masteringActive = true; masterBus.inputAnalyser.disconnect();
} else { masterBus.maximizerCompressor.disconnect();
// Bypassed routing: inputAnalyser -> outputAnalyser if (active) {
masterBus.inputAnalyser.connect(masterBus.outputAnalyser); masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
masterBus.masteringActive = false; 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) { if (!masterBus) {
initMasterBus(audioCtx); initMasterBus(audioCtx);
} }
if (window.currentMasteringSettings) { // The mastering chain is ONLY managed by the masteringSettings effect NOT
toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed); // here. getAudioContext runs constantly (play, stopAll, VU, double-click);
applyMasteringSettings(window.currentMasteringSettings); // 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) { if (window.SonicSF && window.SonicSF.init) {
window.SonicSF.init(audioCtx); 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 = ({ const ProfileModal = ({
isOpen, isOpen,
onClose, onClose,
@@ -4494,7 +4609,14 @@ const ProfileModal = ({
currentProjectId, currentProjectId,
setCurrentProjectId, setCurrentProjectId,
showToast, showToast,
loadAudioBuffersForTracks loadAudioBuffersForTracks,
setAppWarningModal,
bpm,
setBpm,
setMasteringSettings,
setSessionTabs,
setSubTabs,
trackMidiChannelsRef
}) => { }) => {
if (!isOpen) return null; if (!isOpen) return null;
const [activeTab, setActiveTab] = useState('account'); const [activeTab, setActiveTab] = useState('account');
@@ -4685,6 +4807,8 @@ const ProfileModal = ({
} }
setTracks(restoredTracks); setTracks(restoredTracks);
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); }); 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()); setBpm(restoredBpm.toString());
setSelectedTrackId(restoredTracks[0]?.id || '1'); setSelectedTrackId(restoredTracks[0]?.id || '1');
setProjectName(proj.name); setProjectName(proj.name);
@@ -6196,7 +6320,7 @@ 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 = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0); var pvCh = pvTrk ? assignTrackMidiChannel(pvTrk, activeTracks) : 0;
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, st.instrumentProgram, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined);
}); });
@@ -6530,7 +6654,7 @@ 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 = 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); 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) { if (window.SonicSF && window.SonicSF._playNoteFallback) {
const ctx = getAudioContext(); const ctx = getAudioContext();
var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var dwCh = 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 dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch;
var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000)); var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000));
var dwNodes = window.SonicSF._playNoteFallback(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined); 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) { 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 = 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 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, pvTrk ? pvTrk.instrumentProgram : undefined, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined);
if (pvNodes) previewNodesRef.current = pvNodes; if (pvNodes) previewNodesRef.current = pvNodes;
@@ -7167,7 +7291,7 @@ 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 = 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; var kbSynth = kbTrk ? kbTrk.synth_engine : undefined;
const keys = []; const keys = [];
for (let pitch = 127; pitch >= PITCH_START; pitch--) { 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_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), soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null),
synth_engine: t.synth_engine || undefined, synth_engine: t.synth_engine || undefined,
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
server_file_id: t.serverFileId || null, server_file_id: t.serverFileId || null,
items: items items: items
}; };
@@ -8427,6 +8552,16 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText'); renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText');
renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText'); 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 // Wave Observer Oscilloscope Rendering
const woCanvas = woCanvasRef.current; const woCanvas = woCanvasRef.current;
if (woCanvas) { if (woCanvas) {
@@ -11074,6 +11209,10 @@ const App = () => {
const isSfInstrument = instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_'); const isSfInstrument = instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_');
const sfBank = bankNumber !== undefined ? bankNumber : (isSfInstrument ? 0 : undefined); const sfBank = bankNumber !== undefined ? bankNumber : (isSfInstrument ? 0 : undefined);
const sfProg = programNumber !== undefined ? programNumber : 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 => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
const hasInstrument = !!instrumentId; const hasInstrument = !!instrumentId;
@@ -11085,10 +11224,6 @@ const App = () => {
soundfont_program: sfProg !== undefined ? sfProg : 0, soundfont_program: sfProg !== undefined ? sfProg : 0,
soundfont_id: isSfInstrument ? instrumentId.replace('sf_', '') : '' soundfont_id: isSfInstrument ? instrumentId.replace('sf_', '') : ''
} : undefined; } : 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) }; 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); setInstrumentDropdownTrackId(null);
@@ -11097,15 +11232,7 @@ const App = () => {
// Trigger SpessaSynth load + program change when soundfont instrument selected // Trigger SpessaSynth load + program change when soundfont instrument selected
if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) { if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) {
const sfId = instrumentId.replace('sf_', ''); const sfId = instrumentId.replace('sf_', '');
var allTracks = activeTracksRef.current || tracks; window.SonicSF.selectInstrument(mch, sfBank || 0, sfProg || 0, sfId);
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);
} }
setSubTabs(prev => prev.map(s => { setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s; if (s.trackId !== trackId) return s;
@@ -11150,11 +11277,15 @@ const App = () => {
setInstrumentDropdownBtnRect(null); setInstrumentDropdownBtnRect(null);
if (instrumentId && instrumentId.startsWith('sf_')) { if (instrumentId && instrumentId.startsWith('sf_')) {
// Set instrument on track immediately so Synth button shows the name // 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 => { updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t; if (t.id !== trackId) return t;
const sfClean = instrumentId.replace('sf_', ''); const sfClean = instrumentId.replace('sf_', '');
const synthEngine = { type: 'soundfont', plugin_id: instrumentId, soundfont_bank: 0, soundfont_program: 0, soundfont_id: sfClean }; 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); setSelectedSoundFontId(instrumentId);
setSynthCategory('soundfont'); setSynthCategory('soundfont');
@@ -11279,7 +11410,7 @@ const App = () => {
if (arSubs.length > 0) { if (arSubs.length > 0) {
arSubs.forEach(function(as) { arSubs.forEach(function(as) {
var asTrk = allTracks.find(function(t) { return t.id === as.trackId; }); 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 asProg = as.instrumentProgram;
var asSe = as.synth_engine; var asSe = as.synth_engine;
if (window.triggerMidiVuActivity) { if (window.triggerMidiVuActivity) {
@@ -11290,7 +11421,7 @@ const App = () => {
} }
// Route to ALL armed tracks (not just the first one) // Route to ALL armed tracks (not just the first one)
armedTracks.forEach(function(at) { 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 atProg = at.instrumentProgram;
var atSe = at.synth_engine; var atSe = at.synth_engine;
var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null; 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 // Stop the note on ALL tracks (not just armed) to prevent stuck notes
// when ARM is toggled off while a key is held // when ARM is toggled off while a key is held
if (window.SonicSF && window.SonicSF.stopNote) { if (window.SonicSF && window.SonicSF.stopNote) {
var stopTracks = activeTracksRef.current || []; var stopTracks = activeTracksRef.current || [];
stopTracks.forEach(function(st) { stopTracks.forEach(function(st) {
var stCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(st, stopTracks) : (st.midiChannel !== undefined ? st.midiChannel : 0); // Only stop channels that actually carry this track's notes
window.SonicSF.stopNote(stCh, pitch); // 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 // Sustain (CC64), Modulation (CC1), Pitch Bend
@@ -11330,11 +11465,11 @@ const App = () => {
var ccTracks = activeTracksRef.current || []; var ccTracks = activeTracksRef.current || [];
var hasArmed = ccTracks.some(function(t) { return t.isArmed; }); var hasArmed = ccTracks.some(function(t) { return t.isArmed; });
if (hasArmed) { if (hasArmed) {
ccTracks.forEach(function(ct) { ccTracks.forEach(function(ct) {
if (!ct.isArmed) return; if (!ct.isArmed) return;
var ctCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ct, ccTracks) : (ct.midiChannel !== undefined ? ct.midiChannel : 0); var ctCh = assignTrackMidiChannel(ct, ccTracks);
window.SonicSF.controllerChange(ctCh, cc, val); window.SonicSF.controllerChange(ctCh, cc, val);
}); });
} else { } else {
window.SonicSF.controllerChange(midiCh, cc, val); window.SonicSF.controllerChange(midiCh, cc, val);
} }
@@ -11348,11 +11483,11 @@ const App = () => {
var pbTracks = activeTracksRef.current || []; var pbTracks = activeTracksRef.current || [];
var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; }); var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; });
if (hasArmedPB) { if (hasArmedPB) {
pbTracks.forEach(function(pt) { pbTracks.forEach(function(pt) {
if (!pt.isArmed) return; if (!pt.isArmed) return;
var ptCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pt, pbTracks) : (pt.midiChannel !== undefined ? pt.midiChannel : 0); var ptCh = assignTrackMidiChannel(pt, pbTracks);
window.SonicSF.pitchBend(ptCh, bendVal); window.SonicSF.pitchBend(ptCh, bendVal);
}); });
} else { } else {
window.SonicSF.pitchBend(midiCh, bendVal); window.SonicSF.pitchBend(midiCh, bendVal);
} }
@@ -12110,6 +12245,8 @@ const App = () => {
} }
setTracks(restoredTracks); setTracks(restoredTracks);
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); }); 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()); setBpm(restoredBpm.toString());
setSelectedTrackId(restoredTracks[0]?.id || '1'); setSelectedTrackId(restoredTracks[0]?.id || '1');
setProjectName(lastName); setProjectName(lastName);
@@ -13411,9 +13548,9 @@ const App = () => {
var sfId = se.soundfont_id; var sfId = se.soundfont_id;
if (sfId) { if (sfId) {
var allTrks = activeTracksRef.current || activeTracks; var allTrks = activeTracksRef.current || activeTracks;
var trkIdx = 0; // Use the track's dedicated channel (same as every playback path) so the
for (var ti = 0; ti < allTrks.length; ti++) { if (allTrks[ti].id === trackId) { trkIdx = ti; break; } } // preload matches where piano-roll notes will actually sound.
var seCh = (se.soundfont_bank === 128 ? 9 : (trkIdx % 16)); var seCh = assignTrackMidiChannel(track, allTrks);
window.SonicSF.selectInstrument(seCh, se.soundfont_bank || 0, se.soundfont_program || 0, sfId); 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 track = activeTracks.find(t => t.id === trackId);
const destNode = getOrCreateTrackNode(track, context); const destNode = getOrCreateTrackNode(track, context);
const program = track ? track.instrumentProgram : undefined; 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( window.SonicSF.playNote(
pitch, pitch,
velocity, velocity,
@@ -15366,7 +15503,7 @@ const App = () => {
// MIDI items playback // MIDI items playback
const midiItems = track.midiItems || []; const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) { 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 // Ensure instrument is loaded in FluidSynth
if (track.synth_engine && track.synth_engine.type === 'soundfont' && track.synth_engine.soundfont_id) { 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); 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) { if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) {
const notePlayEndMain = Math.min(noteEndMain, secEnd); const notePlayEndMain = Math.min(noteEndMain, secEnd);
const program = subTrack.instrumentProgram; 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) { if (offsetTime < noteStartMain) {
const delay = noteStartMain - offsetTime; const delay = noteStartMain - offsetTime;
@@ -15585,6 +15722,7 @@ const App = () => {
// MIDI items playback // MIDI items playback
const midiItems = track.midiItems || []; const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) { if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
var lcCh = assignTrackMidiChannel(track, tracks);
const bpmVal = parseInt(bpm) || 120; const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal; const secondsPerBeat = 60.0 / bpmVal;
midiItems.forEach(item => { midiItems.forEach(item => {
@@ -15605,7 +15743,9 @@ const App = () => {
durationMs, durationMs,
startTime, startTime,
program, program,
gainNode gainNode,
lcCh,
track.synth_engine
); );
} else { } else {
const remainingDurMs = (noteEndSec - offsetTime) * 1000; const remainingDurMs = (noteEndSec - offsetTime) * 1000;
@@ -15615,7 +15755,9 @@ const App = () => {
remainingDurMs, remainingDurMs,
context.currentTime, context.currentTime,
program, program,
gainNode gainNode,
lcCh,
track.synth_engine
); );
} }
} }
@@ -15635,16 +15777,14 @@ const App = () => {
const instrumentProgram = track ? track.instrumentProgram : undefined; const instrumentProgram = track ? track.instrumentProgram : undefined;
const synthEngine = track ? track.synth_engine : undefined; const synthEngine = track ? track.synth_engine : undefined;
var allTracks = activeTracksRef.current || []; var allTracks = activeTracksRef.current || [];
var mainCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allTracks) : (track ? track.midiChannel : 0); var mainCh = track ? assignTrackMidiChannel(track, allTracks) : 0;
// Compute session beat offset from target item // The piano-roll playhead (st.currentTime) and transport are item-relative:
var sessionBeatOffset = 0; // 0 = item start. Notes must be scheduled relative to the item too, otherwise
var targetTrk = allTracks.find(function(t) { return t.id === st.trackId; }); // items placed later in the project play `item.startTime` seconds in the
if (targetTrk) { // future (silence when pressing play). Ghost notes are already relative to
var targetIt = (targetTrk.midiItems || []).find(function(m) { return m.id === st.target_id; }); // the item window, so no absolute-session offset is applied anywhere here.
if (targetIt) sessionBeatOffset = (targetIt.startTime / ((60.0 / bpmVal) * 4)) * 4;
}
midiNotes.forEach(note => { midiNotes.forEach(note => {
const noteOnBeat = (note.start_beat || 0) + sessionBeatOffset; const noteOnBeat = note.start_beat || 0;
const noteDurBeat = note.duration_beats || 1; const noteDurBeat = note.duration_beats || 1;
const noteStartSec = noteOnBeat * secondsPerBeat; const noteStartSec = noteOnBeat * secondsPerBeat;
const noteDurSec = noteDurBeat * secondsPerBeat; const noteDurSec = noteDurBeat * secondsPerBeat;
@@ -15666,16 +15806,9 @@ const App = () => {
var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined); var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined);
if (ghostProg === undefined && !ghostSynth) return; if (ghostProg === undefined && !ghostSynth) return;
var ghostDest = getOrCreateTrackNode(ghostTrack, context); var ghostDest = getOrCreateTrackNode(ghostTrack, context);
var ghostCh = 0; var ghostCh = ghostTrack ? assignTrackMidiChannel(ghostTrack, allTracks) : 0;
if (ghostTrack) {
if (ghostTrack.midiChannel !== undefined) {
ghostCh = ghostTrack.midiChannel;
} else {
ghostCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ghostTrack, allTracks) : (ghostTrack ? ghostTrack.midiChannel : 0);
}
}
layer.notes.forEach(function(note) { 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 dur = note.duration_beats || 1;
var startSec = beat * secondsPerBeat; var startSec = beat * secondsPerBeat;
var durSec = dur * secondsPerBeat; var durSec = dur * secondsPerBeat;
@@ -23767,7 +23900,14 @@ const App = () => {
currentProjectId: currentProjectId, currentProjectId: currentProjectId,
setCurrentProjectId: setCurrentProjectId, setCurrentProjectId: setCurrentProjectId,
showToast: showToast, showToast: showToast,
loadAudioBuffersForTracks: loadAudioBuffersForTracks loadAudioBuffersForTracks: loadAudioBuffersForTracks,
setAppWarningModal: setAppWarningModal,
bpm: bpm,
setBpm: setBpm,
setMasteringSettings: setMasteringSettings,
setSessionTabs: setSessionTabs,
setSubTabs: setSubTabs,
trackMidiChannelsRef: trackMidiChannelsRef
}), /*#__PURE__*/React.createElement(SaveProjectModal, { }), /*#__PURE__*/React.createElement(SaveProjectModal, {
isOpen: saveProjectModalOpen, isOpen: saveProjectModalOpen,
onClose: () => setSaveProjectModalOpen(false), 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 _activeOscillators = {};
let _gainNode = null; let _gainNode = null;
let _scheduledNotes = []; let _scheduledNotes = [];
let _loadPromises = {};
let _sfloadSeq = 0;
const getCtx = function () { const getCtx = function () {
if (_audioCtx) { if (_audioCtx) {
@@ -84,14 +86,21 @@
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate); 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; var _useScriptNode = true;
try { try {
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js'); await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
console.log("[SonicSF] Worklet registered OK"); console.log("[SonicSF] Worklet registered OK (unused)");
} catch (e) { } catch (e) {
console.warn("[SonicSF] Worklet reg failed:", e); console.warn("[SonicSF] Worklet reg failed:", e);
} }
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
console.log("[SonicSF] Initializing FluidSynth WASM Engine..."); console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
var TOTAL_MEMORY = 256 * 1024 * 1024; var TOTAL_MEMORY = 256 * 1024 * 1024;
@@ -104,6 +113,11 @@
}, },
TOTAL_MEMORY: TOTAL_MEMORY, TOTAL_MEMORY: TOTAL_MEMORY,
printErr: function (msg) { 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); console.warn('[FluidSynth:err]', msg);
} }
}); });
@@ -132,7 +146,15 @@
if (!_useScriptNode) { if (!_useScriptNode) {
try { 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); _workletNode.connect(_gainNode);
console.log("[SonicSF] AudioWorklet node connected via gain"); console.log("[SonicSF] AudioWorklet node connected via gain");
_startRenderLoop(); _startRenderLoop();
@@ -186,11 +208,16 @@
}, },
_tryLoadSFL: function (buf, ext) { _tryLoadSFL: function (buf, ext) {
var fname = '/' + ext + '_' + Date.now(); var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
try { _fluidModule.FS.unlink(fname); } catch (e) {} try { _fluidModule.FS.unlink(fname); } catch (e) {}
_fluidModule.FS.writeFile(fname, new Uint8Array(buf)); _fluidModule.FS.writeFile(fname, new Uint8Array(buf));
var cPath = this._allocCStr(fname); 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); _fluidModule._free(cPath);
try { _fluidModule.FS.unlink(fname); } catch (e) {} try { _fluidModule.FS.unlink(fname); } catch (e) {}
return handle; return handle;
@@ -203,6 +230,24 @@
_currentSfId = sfId; _currentSfId = sfId;
return true; 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 { try {
var cache = window.SonicSFStorage; var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null; var buf = cache ? await cache.getBuffer(sfId) : null;
@@ -258,16 +303,16 @@
if (!ok) return; if (!ok) return;
} }
var engKey = (sfId || '') + ':' + bank + ':' + program; 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); var allocCh = this.allocateChannel(bank);
_engineChMap[engKey] = allocCh; _engineChMap[engKey] = allocCh;
channel = allocCh; channel = allocCh;
} else { } else {
_engineChMap[engKey] = channel; channel = _engineChMap[engKey];
} }
} else { } else if (!_engineChMap[engKey]) {
channel = _engineChMap[engKey]; _engineChMap[engKey] = channel;
} }
var sfHandle = _sfHandleMap.get(sfId); var sfHandle = _sfHandleMap.get(sfId);
if (sfHandle !== undefined) { if (sfHandle !== undefined) {
@@ -423,18 +468,30 @@
var finalBank = usedBank; var finalBank = usedBank;
var finalProg = usedProg; var finalProg = usedProg;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined; var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
if (_channels[ch] && _channels[ch].program !== undefined) { var cachedCh = _channels[ch];
finalBank = _channels[ch].bank; // The note's own synth engine (track instrument) is
finalProg = _channels[ch].program; // authoritative. Channel state is only a cache: it must never
if (_channels[ch].sfId !== undefined) { // mask the track's instrument, otherwise multi-track ARM or a
finalSfId = _channels[ch].sfId; // 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 // Program change at note time, not call time — ensures correct
// 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 cachedCh = _channels[ch];
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId; var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
if ((synthEngine || program !== undefined) && !progAlreadySet) { if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined; var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
+11 -4
View File
@@ -15,17 +15,24 @@ class FluidSynthBridge extends AudioWorkletProcessor {
process(inputs, outputs) { process(inputs, outputs) {
const out = outputs[0]; const out = outputs[0];
if (!out) return true; if (!out || out.length === 0) return true;
this.called++; this.called++;
const numCh = out.length;
const len = out[0].length; const len = out[0].length;
const qL = this.leftQ; const qL = this.leftQ;
const qR = this.rightQ; const qR = this.rightQ;
let fi = 0; let fi = 0;
let si = 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++) { for (let i = 0; i < len; i++) {
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; } if (fi >= qL.length) {
out[0][i] = qL[fi][si]; for (let c = 0; c < numCh; c++) out[c][i] = 0;
out[1][i] = qR[fi][si]; continue;
}
for (let c = 0; c < numCh; c++) {
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
}
si++; si++;
if (si >= qL[fi].length) { fi++; si = 0; } 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/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.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/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/aiGateway.js?v=202607271016"></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=202608022001" defer></script> <script src="/static/js/app.precompiled.js?v=202608031415" 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 {
+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. - **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` - **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. - **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 đó.