Files
SonicForgeStudio/app/static/js/services/unifiedMidiRouter.js
T

180 lines
7.8 KiB
JavaScript

// 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
};
})();