T4: UnifiedMidiRouter - 1 diem dispatch MIDI moi nguon
UnifiedMidiEvent (trackId, channel, command, pitch clamp 0-127, velocity normalize float->int 1-127, sourceType, timestampNs) + UnifiedMidiRouter: engineRegistry Map<trackId, engine> (route theo track), dispatchMidiEvent, activeVoiceTracker key trackId_channel_pitch (dem note-on/off dung - het stuck notes, note-off cuoi moi tat voice), panicAllNotesOff. Chua noi vao app (T5-T7). Test node: pitch clamp, velocity normalize, tracker, panic clear - xanh.
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
// 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. Chưa nối vào app (T5–T7 sẽ đăng ký engine + dispatch).
|
||||||
|
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); } 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
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// T4 test: node tests/unified_midi_router.test.js
|
||||||
|
// Router chưa nối app — test độc lập qua window stub.
|
||||||
|
'use strict';
|
||||||
|
global.window = {};
|
||||||
|
require('../app/static/js/services/unifiedMidiRouter.js');
|
||||||
|
const R = global.window.SonicUnifiedMidiRouter;
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
function makeEngine() {
|
||||||
|
return {
|
||||||
|
playNote(pitch, vel, dur) { calls.push(['on', pitch, vel, dur]); },
|
||||||
|
noteOff(pitch) { calls.push(['off', pitch]); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) pitch clamp
|
||||||
|
const r = new R.UnifiedMidiRouter();
|
||||||
|
const eng = makeEngine();
|
||||||
|
r.registerEngine('t1', eng);
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_ON, pitch: 200, velocity: 1, sourceType: 'TEST' });
|
||||||
|
assert.deepStrictEqual(calls[0], ['on', 127, 127, 500], 'pitch clamp 200->127');
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 0, command: R.COMMAND_NOTE_OFF, pitch: -3 });
|
||||||
|
assert.strictEqual(calls.length, 1, 'note-off khong co voice -> khong goi engine (pitch clamp khong crash)');
|
||||||
|
|
||||||
|
// 2) velocity normalize float->int (100/127 -> 100, 0.5 -> 64)
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 60, velocity: 100 / 127 });
|
||||||
|
assert.deepStrictEqual(calls[1], ['on', 60, 100, 500], 'velocity 100/127 -> 100');
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 1, command: R.COMMAND_NOTE_ON, pitch: 61, velocity: 0.5 });
|
||||||
|
assert.deepStrictEqual(calls[2], ['on', 61, 64, 500], 'velocity 0.5 -> 64');
|
||||||
|
|
||||||
|
// 3) tracker đếm đúng: 2 note-on trùng key -> 1 playNote; off cuối mới tắt
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 100 });
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 2, '2 note-on count=2');
|
||||||
|
const onCalls = calls.filter(c => c[0] === 'on' && c[1] === 64).length;
|
||||||
|
assert.strictEqual(onCalls, 1, 'trung key chi play 1 lan');
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 1, '1 off -> count=1, chua noteOff');
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 2, command: R.COMMAND_NOTE_OFF, pitch: 64 });
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0, 'off cuoi -> count=0');
|
||||||
|
assert.deepStrictEqual(calls[calls.length - 1], ['off', 64], 'off cuoi moi noteOff');
|
||||||
|
|
||||||
|
// 4) 2 track cùng channel+pitch đếm riêng (không lệch voice)
|
||||||
|
const eng2 = makeEngine();
|
||||||
|
r.registerEngine('t2', eng2);
|
||||||
|
r.dispatchMidiEvent({ trackId: 't2', channel: 2, command: R.COMMAND_NOTE_ON, pitch: 64, velocity: 90 });
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t2', 2, 64), 1);
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t1', 2, 64), 0);
|
||||||
|
|
||||||
|
// 5) panic clear: note đang giữ -> noteOff + tracker rỗng
|
||||||
|
r.dispatchMidiEvent({ trackId: 't1', channel: 3, command: R.COMMAND_NOTE_ON, pitch: 72, velocity: 80 });
|
||||||
|
r.panicAllNotesOff();
|
||||||
|
assert.strictEqual(r.activeVoiceCount('t1', 3, 72), 0, 'panic xoa tracker');
|
||||||
|
assert.deepStrictEqual(calls[calls.length - 1], ['off', 72], 'panic gui noteOff');
|
||||||
|
|
||||||
|
console.log('unified_midi_router: ALL TESTS PASSED');
|
||||||
Reference in New Issue
Block a user