diff --git a/app/static/js/services/unifiedMidiRouter.js b/app/static/js/services/unifiedMidiRouter.js new file mode 100644 index 0000000..b03d829 --- /dev/null +++ b/app/static/js/services/unifiedMidiRouter.js @@ -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 + }; +})(); diff --git a/tests/unified_midi_router.test.js b/tests/unified_midi_router.test.js new file mode 100644 index 0000000..556f0d3 --- /dev/null +++ b/tests/unified_midi_router.test.js @@ -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');