// SonicForge Unified MIDI Router // Mọi nguồn MIDI (keybed preview, timeline scheduler, hardware MIDI) → chuẩn // hóa UnifiedMidiEvent → UnifiedMidiRouter → engine theo trackId. Một điểm // dispatch duy nhất: activeVoiceTracker đếm note-on/off đúng (hết stuck // notes), panicAllNotesOff() quét toàn bộ voice khi đổi instrument/engine // giữa chừng. // Trạng thái nối (đối chiếu GIAI_PHAP 2026-08): ĐÃ nối cho PREVIEW — app.jsx // _routeNoteOn/_routeNoteOff (L212-260) đăng ký TrackInstrument per-track và // dispatch qua router (keybed/click/draw/timeline scheduler gọi _routePreviewNote // trước, chỉ fallback SonicSF.playNote khi router không xử lý). CHƯA nối cho // timeline scheduler per-item (vẫn gọi thẳng SonicSF.playNote / scheduleNativeSfItem). window.SonicUnifiedMidiRouter = window.SonicUnifiedMidiRouter || {}; (function () { var COMMAND_NOTE_ON = 0x90; var COMMAND_NOTE_OFF = 0x80; var COMMAND_CC = 0xB0; // Pitch: ép int 0-127 (spec "pitch clamp 0-127"). function clampPitch(pitch) { pitch = Math.round(Number(pitch) || 0); if (pitch < 0) return 0; if (pitch > 127) return 127; return pitch; } // Velocity: float 0-1 (vd 100/127) hoặc int → ép int 1-127 (note-on); // note-off velocity = 0. Spec "velocity normalize float→int". function normalizeVelocity(velocity, isNoteOn) { var v = Number(velocity); if (!isFinite(v)) v = 0; if (v > 0 && v <= 1) v = v * 127; v = Math.round(v); if (v < 0) v = 0; if (v > 127) v = 127; if (isNoteOn && v <= 0) v = 1; return v; } function UnifiedMidiRouter() { this.engineRegistry = new Map(); // trackId → TrackInstrument // activeVoiceTracker: key `${trackId}_${channel}_${pitch}` → { count, engine }. // (Spec dùng `${channel}_${pitch}`; thêm trackId vì engine theo track — // 2 track cùng channel+pitch phải đếm riêng, không lệch voice.) this._voices = new Map(); } // Đăng ký engine cho track (null = xóa). Đổi instrument → registerEngine // track mới; panic trước đó để không sót voice cũ. UnifiedMidiRouter.prototype.registerEngine = function (trackId, engine) { if (trackId === undefined || trackId === null) return; if (engine) this.engineRegistry.set(trackId, engine); else this.engineRegistry.delete(trackId); }; // Dispatch 1 event từ mọi nguồn. Không ném — voice tracker luôn đếm đúng. // Trả true nếu event là note (đã route/count), false nếu không xử lý. UnifiedMidiRouter.prototype.dispatchMidiEvent = function (evt) { if (!evt) return false; var trackId = evt.trackId; var engine = (trackId !== undefined && trackId !== null) ? this.engineRegistry.get(trackId) : null; var channel = Math.round(Number(evt.channel) || 0); if (channel < 0) channel = 0; if (channel > 15) channel = 15; var pitch = clampPitch(evt.pitch); var cmd = Number(evt.command); var key = trackId + '_' + channel + '_' + pitch; var isNoteOn = (cmd === COMMAND_NOTE_ON); var isNoteOff = (cmd === COMMAND_NOTE_OFF) || (cmd === COMMAND_NOTE_ON && !Number(evt.velocity)); if (isNoteOn) { var vel = normalizeVelocity(evt.velocity, true); var cur = this._voices.get(key); if (!cur) { cur = { count: 0, engine: engine }; this._voices.set(key, cur); } cur.engine = engine; cur.count += 1; // Chỉ trigger âm ở note-on đầu tiên; các note-on trùng key chỉ tăng // count (sustain lặp) — note-off cuối cùng mới tắt voice. if (cur.count === 1 && engine && engine.playNote) { try { engine.playNote(pitch, vel, evt.durationMs || 500, evt.startTime); } catch (e) {} } return true; } if (isNoteOff) { var cur2 = this._voices.get(key); if (cur2 && cur2.count > 0) { cur2.count -= 1; var eng2 = cur2.engine; if (cur2.count <= 0) { this._voices.delete(key); if (eng2 && eng2.noteOff) { try { eng2.noteOff(pitch); } catch (e) {} } } } return true; } return false; // CC / unknown }; // Hardware MIDI keyboard message → dispatch tới targets đã gom sẵn (armed // tracks). app.jsx thu thập noteOnTargets [{trackId, ch, engine}] ở note-on // và noteOffTargets [{trackId, ch}] ở note-off, gọi 1 lần mỗi message. // parse giống handler cũ (cmd 4-bit, pitch clamp, velocity 1-127) rồi đẩy // qua dispatchMidiEvent → 3 nguồn (keybed/scheduler/hardware) chung router. UnifiedMidiRouter.prototype.handleHardwareKeyboardMessage = function (msg, noteOnTargets, noteOffTargets) { var cmd = Number(msg[0]) >> 4; var pitch = clampPitch(msg[1]); var rawVel = Number(msg[2]) || 0; var velocity = Math.min(127, Math.max(1, Math.round(rawVel))); var noteOn = (cmd === 0x9 && rawVel > 0); var noteOff = (cmd === 0x8 || (cmd === 0x9 && rawVel === 0)); if (noteOn && noteOnTargets) { noteOnTargets.forEach(function (t) { if (!t || t.trackId === undefined || t.trackId === null) return; if (t.engine) this.registerEngine(t.trackId, t.engine); this.dispatchMidiEvent({ trackId: t.trackId, command: COMMAND_NOTE_ON, channel: t.ch, pitch: pitch, velocity: velocity / 127, durationMs: 60000, source: 'HARDWARE_KEYBOARD' }); }, this); } if (noteOff && noteOffTargets) { noteOffTargets.forEach(function (t) { if (!t || t.trackId === undefined || t.trackId === null) return; this.dispatchMidiEvent({ trackId: t.trackId, command: COMMAND_NOTE_OFF, channel: t.ch, pitch: pitch, source: 'HARDWARE_KEYBOARD' }); }, this); } console.log('[MidiRouter] hardware ->', { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity, targets: (noteOnTargets ? noteOnTargets.length : 0) + '/' + (noteOffTargets ? noteOffTargets.length : 0) }); return { noteOn: noteOn, noteOff: noteOff, pitch: pitch, velocity: velocity }; }; // All Notes Off: quét voice tracker, gửi note-off cho engine sở hữu từng // voice, xóa tracker. Gọi khi dừng playback / đổi instrument giữa chừng. UnifiedMidiRouter.prototype.panicAllNotesOff = function () { var self = this; this._voices.forEach(function (v, key) { if (v.engine && v.engine.noteOff) { var parts = key.split('_'); var pitch = parseInt(parts[parts.length - 1], 10); try { v.engine.noteOff(pitch); } catch (e) {} } }); this._voices.clear(); }; // Số voice active cho 1 (track, channel, pitch) — test/debug. UnifiedMidiRouter.prototype.activeVoiceCount = function (trackId, channel, pitch) { var cur = this._voices.get(trackId + '_' + channel + '_' + pitch); return cur ? cur.count : 0; }; window.SonicUnifiedMidiRouter = { UnifiedMidiRouter: UnifiedMidiRouter, COMMAND_NOTE_ON: COMMAND_NOTE_ON, COMMAND_NOTE_OFF: COMMAND_NOTE_OFF, COMMAND_CC: COMMAND_CC }; })();