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
This commit is contained in:
+50
-20
@@ -4637,7 +4637,7 @@ const ProfileModal = ({
|
|||||||
if (!sfId) continue;
|
if (!sfId) continue;
|
||||||
const bank = se ? (se.soundfont_bank !== undefined ? se.soundfont_bank : 0) : (t.soundfont_bank !== undefined ? t.soundfont_bank : 0);
|
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 prog = se ? (se.soundfont_program !== undefined ? se.soundfont_program : 0) : (t.soundfont_program !== undefined ? t.soundfont_program : 0);
|
||||||
var ch = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(t, tracks) : (t.midiChannel !== undefined ? t.midiChannel : 0);
|
var ch = t && assignTrackMidiChannel(t, tracks);
|
||||||
try {
|
try {
|
||||||
await window.SonicSF.selectInstrument(ch, bank, prog, sfId);
|
await window.SonicSF.selectInstrument(ch, bank, prog, sfId);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -4702,6 +4702,7 @@ 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); });
|
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');
|
||||||
@@ -7888,6 +7889,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
|
||||||
};
|
};
|
||||||
@@ -11088,10 +11090,41 @@ const App = () => {
|
|||||||
"Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal",
|
"Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal",
|
||||||
"Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot"
|
"Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot"
|
||||||
];
|
];
|
||||||
|
// ── 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 never cross-contaminates instruments.
|
||||||
|
const trackMidiChannelsRef = useRef({});
|
||||||
|
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;
|
||||||
|
};
|
||||||
const setTrackInstrumentWithProgram = (trackId, instrumentId, programNumber, displayName, bankNumber) => {
|
const setTrackInstrumentWithProgram = (trackId, instrumentId, programNumber, displayName, bankNumber) => {
|
||||||
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;
|
||||||
@@ -11103,10 +11136,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);
|
||||||
@@ -11115,15 +11144,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;
|
||||||
@@ -11168,11 +11189,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');
|
||||||
@@ -11297,7 +11322,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) {
|
||||||
@@ -11308,7 +11333,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;
|
||||||
@@ -11332,7 +11357,11 @@ const App = () => {
|
|||||||
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 —
|
||||||
|
// 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);
|
window.SonicSF.stopNote(stCh, pitch);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -11350,7 +11379,7 @@ const App = () => {
|
|||||||
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 {
|
||||||
@@ -11368,7 +11397,7 @@ const App = () => {
|
|||||||
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 {
|
||||||
@@ -12128,6 +12157,7 @@ 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); });
|
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');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/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=202608031040" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608031100" 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 {
|
||||||
|
|||||||
@@ -1209,3 +1209,8 @@
|
|||||||
- **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` và `restoreLastSessionProject` → nhấn MIDI key đầu tiên không còn lag (font đã nạp sẵn).
|
- **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` và `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`
|
- **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.
|
- **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user