diff --git a/IMPLEMENT_PHASE_3.md b/IMPLEMENT_PHASE_3.md
index a48fdf9..3eeab67 100644
--- a/IMPLEMENT_PHASE_3.md
+++ b/IMPLEMENT_PHASE_3.md
@@ -71,10 +71,21 @@
## PHASE 4 — NÚT FX TCP
-(chưa bắt đầu)
+- [x] Nút "FX" trên Track Control Panel (TCP) mỗi track: `onClick → window.__openFxRack(track.id, track.name)` → `setFxRackTarget` → mở `FXRackModal`; sáng cyan khi track có `fxChain` (builtin/VST FX).
+- [x] `window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName });`
+- [x] Fix lookup track trong modal: `(activeTracksRef.current || []).find(t => t.id === fxRackTarget.trackId) || tracks.find(...) || null` — an toàn khi activeTracks là session-tab clones ≠ root tracks (trước: `tracks.find` có thể null → modal không mở).
+- [x] Gộp chung vào commit Phase 3 `5fc45a5`.
## PHASE 5 — VIRTUAL MIDI KEYBOARD
-(chưa bắt đầu)
+- [x] Component `VirtualMidiKeyboard` (top-level, trước `const App`): floating draggable QWERTY keybed — drag header (mousemove/mouseup document), Oct -/+ (0..7), Vel slider 1..127 (default 100), Channel select 1..16 (default = channel track arm/select đầu tiên + 1; chỉ hiển thị, routing vẫn per-track alloc — ponytail), Transpose -24..24, Scale dropdown (None/Major/Minor/Pentatonic Major/Pentatonic Minor/Dorian/Mixolydian — highlight ring-amber chỉ hiển thị không chặn), keybed 15 white + 10 black (KEY_W=24 KEY_H=84, BLACK_W=14 BLACK_H=54 zIndex 5, black left=(wi+1)*KEY_W-BLACK_W/2), highlight `activePitches.has(pitch)` (white bg-emerald-500 / black bg-emerald-600), mousedown noteOn / mouseup+mouseleave noteOff, nhãn C keys `C{octave}`, footer hint QWERTY.
+- [x] Mapping QWERTY (spec §3 — BIG_PHASE_3 §5.2 cũ ghi sai thiếu H): Lower `Z=0 S=1 X=2 D=3 C=4 V=5 G=6 B=7 H=8 N=9 J=10 M=11`, Upper `Q=12 2=13 W=14 3=15 E=16 R=17 5=18 T=19 6=20 Y=21 7=22 U=23 I=24` (I = +2 oct C). Pitch = `(Octave+1)*12 + offset + transpose`, clamp 0..127, vel = velocity/127.
+- [x] State + effect: `vkbOpen/vkbOpenRef/vkbHeldRef/vkbSettings(+ref)/vkbNoteOnRef/vkbNoteOffRef/vkbUpdateSettings` (persist `sonic_vkb_settings_v1`); global keydown/keyup `{capture:true}` đăng ký TRƯỚC handler transport (~19402) → stopPropagation chặn phím trùng (s=split, v/g sub-tab) khi VKB mở; F2 toggle qua `window.__toggleVkb`; bỏ `e.repeat`; KHÔNG bắt khi focus input/textarea/contentEditable; bỏ qua ctrl/meta/alt; không chặn Space/R.
+- [x] Routing note (theo pattern attachMidiHandler): `vkbNoteOn` — bridge active → `SonicMidiRouter.pushEvent({cmd:'NOTE_ON', channel: t.id, pitch, velocity (0..1), percussion, live:true})` cho ALL armed tracks (fallback all tracks + toast 1 lần khi không arm); native SF/VST qua `playNativeSfNote/playNativeVstNote(t, pitch, vel, 60000, undefined, 'vkb_'+t.id+'_'+pitch)` (key prefix cho stop); `SonicSF.playNote` fallback; Carla noteOn; VU `triggerMidiVuActivity(t.id, vel*127)`; giữ `heldMidiNotesRef.current[t.id]`; `vkbNoteOff` — pushEvent NOTE_OFF / stopNativeSfNote+stopNativeVstNote / `SonicSF.stopNote` / Carla noteOff, giảm counter.
+- [x] Live record: forward `rec.handleMIDIMessage({data:[0x90|0x80,pitch,vel]}, null)` tới mọi recorder trong `activeMIDIRecordersRef` (sourceInputId null → không bị filter selectedMidiInputId) — timeline take + piano roll đều ghi.
+- [x] Menu Tools: item "Virtual MIDI Keyboard" icon 'piano' shortcut 'F2' → `window.__toggleVkb()`; render `vkbOpen && ` sau FXRackModal; đóng VKB → release mọi phím đang giữ (vkbHeldRef).
+- [x] Build: `node build.mjs` OK; copy `app.precompiled.js` + `index.html` sang bundle `src-tauri/target/release/daw_engine/_internal/app/` (frontend-only); bump cache-bust `?v=202608211150` (repo + bundle); md5 bundle khớp (`2fabf1b0...`).
+- [x] Verify chạy thật (Playwright, login admin/admin123): F2 mở/đóng; bấm Z..M → highlight C4..B4 đúng pitch (60..71), Q=72, Oct+ → C5; highlight tắt khi release; input focus → không bắn note; kéo thả header → persist x/y, reload khôi phục vị trí; record (arm + input MIDI_KEYBOARD:ALL) → R → bấm Z → recorder nhận NOTE_ON [144,60,100] + NOTE_OFF [128,60,0] (đợi ~5s sau REC — headless context.resume chậm, không phải bug forward; cùng hạn chế với hardware MIDI).
+- [x] 3 regression cũ PASS: `shm_note_energy` energy 0 (app chưa load instrument — đúng); `test_sf2_ring` energy 12.337→0.046 (= baseline Phase 3); `test_vst2_gui` DUNE host + editor VSTGUI, proc alive.
## PHASE 6 — CHORDS PANEL
(chưa bắt đầu)
diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 1e5ecb0..7ecb856 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -16303,6 +16303,126 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
);
};
+// ── Virtual MIDI Keyboard component (Phase 5) ──────────────────────────────
+// Floating draggable QWERTY keyboard — F2 / menu Tools. Không nuốt Space/R
+// (handler toàn cục chỉ bắt phím trong VKB_MAP). Persist localStorage.
+const VKB_WHITE_OFFSETS = [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23, 24];
+const VKB_BLACK_OFFSETS = [1, 3, 6, 8, 10, 13, 15, 18, 20, 22];
+const VKB_KEY_LABELS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
+const VKB_SCALES = {
+ 'None': null,
+ 'Major': [0, 2, 4, 5, 7, 9, 11],
+ 'Minor': [0, 2, 3, 5, 7, 8, 10],
+ 'Pentatonic Major': [0, 2, 4, 7, 9],
+ 'Pentatonic Minor': [0, 3, 5, 7, 10],
+ 'Dorian': [0, 2, 3, 5, 7, 9, 10],
+ 'Mixolydian': [0, 2, 4, 5, 7, 9, 10]
+};
+const VirtualMidiKeyboard = ({ settings, onChange, tracks, activePitches, onNoteOn, onNoteOff, onClose }) => {
+ const KEY_W = 24;
+ const KEY_H = 84;
+ const BLACK_W = 14;
+ const BLACK_H = 54;
+ const octave = Math.max(0, Math.min(7, parseInt(settings.octave) || 4));
+ const basePitch = (octave + 1) * 12;
+ const scale = VKB_SCALES[settings.scale] || null;
+ const inScale = (pitch) => { if (!scale) return false; const pc = ((pitch % 12) + 12) % 12; return scale.indexOf(pc) !== -1; };
+ const velNum = Math.max(1, Math.min(127, parseInt(settings.velocity) || 100));
+ // Channel hiển thị: user đã chọn (1..16) hoặc mặc định = channel track arm/select đầu tiên
+ let displayChannel = parseInt(settings.channel) || null;
+ if (displayChannel === null) {
+ try {
+ const trks = tracks || [];
+ if (trks.length) {
+ const t = trks.find(x => x.isArmed) || trks[0];
+ displayChannel = assignTrackMidiChannel(t, trks) + 1;
+ }
+ } catch (e) {}
+ if (displayChannel === null) displayChannel = 1;
+ }
+ const startDrag = (e) => {
+ e.preventDefault();
+ const sx = e.clientX, sy = e.clientY;
+ const ox = settings.x, oy = settings.y;
+ const onMove = (ev) => {
+ onChange({ x: Math.max(-280, Math.min(window.innerWidth - 80, ox + (ev.clientX - sx))), y: Math.max(0, Math.min(window.innerHeight - 36, oy + (ev.clientY - sy))) });
+ };
+ const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ };
+ const fireOn = (e, pitch) => { e.preventDefault(); e.stopPropagation(); onNoteOn(pitch, velNum / 127); };
+ const whiteKeys = VKB_WHITE_OFFSETS.map(function (off, wi) {
+ const pitch = Math.min(127, basePitch + off);
+ const active = activePitches && activePitches.has(pitch);
+ const sc = inScale(pitch);
+ return /*#__PURE__*/React.createElement("div", {
+ key: 'vkbw' + off,
+ onMouseDown: (e) => fireOn(e, pitch),
+ onMouseUp: () => onNoteOff(pitch),
+ onMouseLeave: () => onNoteOff(pitch),
+ style: { position: 'absolute', left: wi * KEY_W, top: 0, width: KEY_W, height: KEY_H, boxSizing: 'border-box' },
+ className: "flex items-end justify-center pb-1 text-[9px] font-bold cursor-pointer rounded-b transition " + (active ? 'bg-emerald-500 text-white' : 'bg-zinc-100 text-zinc-600 hover:bg-amber-100') + (sc ? ' ring-1 ring-inset ring-amber-400' : '')
+ }, (off % 12 === 0 ? VKB_KEY_LABELS[0] + (Math.floor(pitch / 12) - 1) : ''));
+ });
+ const blackKeys = VKB_BLACK_OFFSETS.map(function (off) {
+ const pitch = Math.min(127, basePitch + off);
+ const active = activePitches && activePitches.has(pitch);
+ const sc = inScale(pitch);
+ const wi = VKB_WHITE_OFFSETS.indexOf(off - 1);
+ return /*#__PURE__*/React.createElement("div", {
+ key: 'vkbb' + off,
+ onMouseDown: (e) => fireOn(e, pitch),
+ onMouseUp: () => onNoteOff(pitch),
+ onMouseLeave: () => onNoteOff(pitch),
+ style: { position: 'absolute', left: (wi + 1) * KEY_W - BLACK_W / 2, top: 0, width: BLACK_W, height: BLACK_H, boxSizing: 'border-box', zIndex: 5 },
+ className: "cursor-pointer rounded-b transition " + (active ? 'bg-emerald-600' : 'bg-zinc-950 hover:bg-zinc-800') + (sc ? ' ring-1 ring-inset ring-amber-400' : '')
+ });
+ });
+ return /*#__PURE__*/React.createElement("div", {
+ style: { position: 'fixed', left: settings.x, top: settings.y, zIndex: 9990, width: 15 * KEY_W + 12 },
+ className: "bg-[#1c1c1e] border border-zinc-700 rounded-xl shadow-2xl select-none"
+ },
+ /*#__PURE__*/React.createElement("div", {
+ onMouseDown: startDrag,
+ className: "flex items-center justify-between px-3 py-1.5 cursor-move bg-zinc-800 rounded-t-xl border-b border-zinc-700"
+ },
+ /*#__PURE__*/React.createElement("span", { className: "text-[10px] font-bold text-zinc-300 tracking-wide" }, "VIRTUAL MIDI KEYBOARD"),
+ /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" },
+ /*#__PURE__*/React.createElement("span", { className: "text-[9px] text-zinc-500" }, "F2"),
+ /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); onClose(); }, className: "text-zinc-500 hover:text-red-400 text-xs px-1" }, "\u2715")
+ )
+ ),
+ /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 px-2 py-1.5 border-b border-zinc-800 text-[10px] text-zinc-400 flex-wrap" },
+ /*#__PURE__*/React.createElement("button", { onClick: () => onChange({ octave: Math.max(0, octave - 1) }), className: "px-1.5 py-0.5 bg-zinc-800 hover:bg-zinc-700 rounded" }, "Oct -"),
+ /*#__PURE__*/React.createElement("span", { className: "font-mono text-amber-400" }, "Octave: " + octave),
+ /*#__PURE__*/React.createElement("button", { onClick: () => onChange({ octave: Math.min(7, octave + 1) }), className: "px-1.5 py-0.5 bg-zinc-800 hover:bg-zinc-700 rounded" }, "Oct +"),
+ /*#__PURE__*/React.createElement("label", { className: "flex items-center gap-1" }, "Vel",
+ /*#__PURE__*/React.createElement("input", { type: "range", min: 1, max: 127, value: velNum, onChange: (e) => onChange({ velocity: parseInt(e.target.value) || 100 }), className: "w-16" }),
+ /*#__PURE__*/React.createElement("span", { className: "font-mono w-7 text-right" }, velNum)
+ ),
+ /*#__PURE__*/React.createElement("label", { className: "flex items-center gap-1" }, "Ch",
+ /*#__PURE__*/React.createElement("select", { value: displayChannel, onChange: (e) => onChange({ channel: parseInt(e.target.value) }), className: "bg-zinc-800 text-zinc-300 rounded px-1 py-0.5" },
+ Array.from({ length: 16 }, function (_, i) { return /*#__PURE__*/React.createElement("option", { key: i + 1, value: i + 1 }, i + 1); })
+ )
+ ),
+ /*#__PURE__*/React.createElement("label", { className: "flex items-center gap-1" }, "Tr",
+ /*#__PURE__*/React.createElement("input", { type: "number", min: -24, max: 24, value: settings.transpose, onChange: (e) => onChange({ transpose: parseInt(e.target.value) || 0 }), className: "bg-zinc-800 text-zinc-300 rounded px-1 py-0.5 w-10 font-mono" })
+ ),
+ /*#__PURE__*/React.createElement("label", { className: "flex items-center gap-1" }, "Scale",
+ /*#__PURE__*/React.createElement("select", { value: settings.scale, onChange: (e) => onChange({ scale: e.target.value }), className: "bg-zinc-800 text-zinc-300 rounded px-1 py-0.5" },
+ Object.keys(VKB_SCALES).map(function (s) { return /*#__PURE__*/React.createElement("option", { key: s, value: s }, s); })
+ )
+ )
+ ),
+ /*#__PURE__*/React.createElement("div", { className: "relative mx-1 my-2", style: { width: 15 * KEY_W, height: KEY_H } },
+ whiteKeys,
+ blackKeys
+ ),
+ /*#__PURE__*/React.createElement("div", { className: "px-2 pb-1.5 text-[9px] text-zinc-600" }, "QWERTY: Z S X D C V G B H N J M / Q 2 W 3 E R 5 T 6 Y 7 U I")
+ );
+};
+
const App = () => {
// ── State Definitions ──
const [tracks, setTracks] = useState([{
@@ -17058,6 +17178,70 @@ const App = () => {
React.useEffect(() => {
if (showExportPanel) setTimeout(() => { try { window.lucide && window.lucide.createIcons(); } catch (e) { } }, 80);
}, [showExportPanel]);
+
+ // ── Virtual MIDI Keyboard (Phase 5) ─────────────────────────────────────
+ const [vkbOpen, setVkbOpen] = useState(false);
+ const vkbOpenRef = useRef(false);
+ vkbOpenRef.current = vkbOpen;
+ const vkbHeldRef = useRef(new Set());
+ const [vkbSettings, setVkbSettings] = useState(function () {
+ try {
+ const saved = JSON.parse(localStorage.getItem('sonic_vkb_settings_v1') || 'null');
+ if (saved && typeof saved === 'object') return Object.assign({ x: 40, y: 120, octave: 4, velocity: 100, channel: null, transpose: 0, scale: 'None' }, saved);
+ } catch (e) {}
+ return { x: 40, y: 120, octave: 4, velocity: 100, channel: null, transpose: 0, scale: 'None' };
+ }());
+ const vkbSettingsRef = useRef(vkbSettings);
+ vkbSettingsRef.current = vkbSettings;
+ const vkbNoteOnRef = useRef(null);
+ const vkbNoteOffRef = useRef(null);
+ const vkbUpdateSettings = (patch) => {
+ setVkbSettings(prev => {
+ const next = Object.assign({}, prev, patch);
+ try { localStorage.setItem('sonic_vkb_settings_v1', JSON.stringify(next)); } catch (e) {}
+ return next;
+ });
+ };
+ // Global keydown: F2 toggle + QWERTY notes khi VKB mở. capture:true + đăng ký
+ // TRƯỚC handler transport (~19402) → stopPropagation chặn phím tắt trùng
+ // (s=split, v/g sub-tab) khi VKB đang mở; KHÔNG chặn Space/R. Note KHÔNG bắn
+ // khi focus trong input/textarea (pass-through gõ văn bản bình thường).
+ React.useEffect(() => {
+ const VKB_MAP = { z: 0, s: 1, x: 2, d: 3, c: 4, v: 5, g: 6, b: 7, h: 8, n: 9, j: 10, m: 11, q: 12, '2': 13, w: 14, '3': 15, e: 16, r: 17, '5': 18, t: 19, '6': 20, y: 21, '7': 22, u: 23, i: 24 };
+ const handler = (e) => {
+ if (e.type === 'keydown' && e.key === 'F2') {
+ e.preventDefault();
+ if (window.__toggleVkb) window.__toggleVkb(); else setVkbOpen(p => !p);
+ return;
+ }
+ if (!vkbOpenRef.current) return;
+ const target = e.target;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+ const k = e.key && e.key.length === 1 ? e.key.toLowerCase() : null;
+ if (k === null || !(k in VKB_MAP)) return;
+ const s = vkbSettingsRef.current;
+ const offset = VKB_MAP[k];
+ const pitch = Math.max(0, Math.min(127, (s.octave + 1) * 12 + offset + (parseInt(s.transpose) || 0)));
+ if (e.type === 'keydown') {
+ if (e.repeat) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const vel = Math.max(1, Math.min(127, parseInt(s.velocity) || 100)) / 127;
+ if (vkbNoteOnRef.current) vkbNoteOnRef.current(pitch, vel);
+ } else {
+ e.preventDefault();
+ e.stopPropagation();
+ if (vkbNoteOffRef.current) vkbNoteOffRef.current(pitch);
+ }
+ };
+ window.addEventListener('keydown', handler, { capture: true });
+ window.addEventListener('keyup', handler, { capture: true });
+ return () => {
+ window.removeEventListener('keydown', handler, { capture: true });
+ window.removeEventListener('keyup', handler, { capture: true });
+ };
+ }, []);
const [showAIPanel, setShowAIPanel] = useState(true);
const [showSelectionPanel, setShowSelectionPanel] = useState(false);
const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(false);
@@ -21730,6 +21914,95 @@ const App = () => {
};
window.showToast = showToast;
+ // ── Virtual MIDI Keyboard note routing (Phase 5) ────────────────────────
+ // NOTE_ON/NOTE_OFF theo pattern attachMidiHandler (hardware MIDI): bridge →
+ // per-track channel alloc; native SF/VST; SonicSF fallback; Carla; VU + giữ
+ // counter (heldMidiNotesRef); forward recorder (live record vào midiItems).
+ const vkbNoteOn = (pitch, vel) => {
+ try { if (window.__ensureMasteringRouting) window.__ensureMasteringRouting(); } catch (er) {}
+ const allTracks = activeTracksRef.current || [];
+ const armed = allTracks.filter(function (t) { return t.isArmed; });
+ const targets = armed.length ? armed : allTracks;
+ if (!armed.length && !window.__vkbNoArmToast) {
+ window.__vkbNoArmToast = true;
+ showToast('Không có track nào ARM — chơi vào tất cả track.', 'info');
+ }
+ activeMidiPitchesRef.current.add(pitch);
+ setActiveMidiPitches(new Set(activeMidiPitchesRef.current));
+ vkbHeldRef.current.add(pitch);
+ targets.forEach(function (t) {
+ const ctx = resolveTrackInstrumentCtx(t, allTracks);
+ ensureSonicInstrument(ctx);
+ if (window.triggerMidiVuActivity) window.triggerMidiVuActivity(t.id, Math.round(vel * 127));
+ try { heldMidiNotesRef.current[t.id] = (heldMidiNotesRef.current[t.id] || 0) + 1; } catch (err) {}
+ const percussion = !!(ctx && ctx.synthEngine && ctx.synthEngine.soundfont_bank === 128);
+ if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
+ try { if (window.__ensureBridgeProgram) window.__ensureBridgeProgram(t.id, ctx.program, ctx.synthEngine); } catch (er) {}
+ try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: t.id, pitch: pitch, velocity: vel, percussion: percussion, live: true }); } catch (e) {}
+ } else if (isStandaloneSf() && (isSfTrackEngine(ctx.synthEngine) || isVstTrackEngine(ctx.synthEngine)) && !shouldRouteCarla(ctx.synthEngine)) {
+ if (isSfTrackEngine(ctx.synthEngine)) playNativeSfNote(t, pitch, vel, 60000, undefined, 'vkb_' + t.id + '_' + pitch);
+ else playNativeVstNote(t, pitch, vel, 60000, undefined, 'vkb_' + t.id + '_' + pitch);
+ } else if (window.SonicSF) {
+ const dest = (activeTrackNodesRef.current[t.id] && activeTrackNodesRef.current[t.id].gainNode) || null;
+ window.SonicSF.playNote(pitch, Math.max(1, Math.round(vel * 127)), 60000, undefined, ctx.program, dest, ctx.ch, ctx.synthEngine);
+ }
+ if (window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoute(ctx.synthEngine, t.isArmed)) {
+ try { window.SonicCarlaMidi.noteOn(ctx.ch, pitch, Math.max(1, Math.round(vel * 127))); } catch (e) {}
+ }
+ });
+ // Live record: forward tới mọi recorder đang chạy (timeline take / piano roll)
+ if (activeMIDIRecordersRef.current) {
+ const msg = { data: [0x90, pitch, Math.max(1, Math.round(vel * 127))] };
+ for (const trackId in activeMIDIRecordersRef.current) {
+ const rec = activeMIDIRecordersRef.current[trackId];
+ if (rec) { try { rec.handleMIDIMessage(msg, null); } catch (e) {} }
+ }
+ }
+ };
+ const vkbNoteOff = (pitch) => {
+ vkbHeldRef.current.delete(pitch);
+ activeMidiPitchesRef.current.delete(pitch);
+ setActiveMidiPitches(new Set(activeMidiPitchesRef.current));
+ const allTracks = activeTracksRef.current || [];
+ allTracks.forEach(function (t) {
+ const ctx = resolveTrackInstrumentCtx(t, allTracks);
+ const percussion = !!(ctx && ctx.synthEngine && ctx.synthEngine.soundfont_bank === 128);
+ if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
+ try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: t.id, pitch: pitch, velocity: 0, percussion: percussion, live: true }); } catch (e) {}
+ } else if (window.SonicSF && window.SonicSF.stopNote) {
+ if (isStandaloneSf()) { stopNativeSfNote('vkb_' + t.id + '_' + pitch); stopNativeVstNote('vkb_' + t.id + '_' + pitch); }
+ window.SonicSF.stopNote(ctx.ch, pitch);
+ if (window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoute(t.synth_engine, t.isArmed)) {
+ try { window.SonicCarlaMidi.noteOff(ctx.ch, pitch); } catch (e) {}
+ }
+ }
+ try {
+ if (heldMidiNotesRef.current[t.id] !== undefined) {
+ heldMidiNotesRef.current[t.id] = Math.max(0, heldMidiNotesRef.current[t.id] - 1);
+ if (heldMidiNotesRef.current[t.id] === 0) delete heldMidiNotesRef.current[t.id];
+ }
+ } catch (err) {}
+ });
+ if (activeMIDIRecordersRef.current) {
+ const msg = { data: [0x80, pitch, 0] };
+ for (const trackId in activeMIDIRecordersRef.current) {
+ const rec = activeMIDIRecordersRef.current[trackId];
+ if (rec) { try { rec.handleMIDIMessage(msg, null); } catch (e) {} }
+ }
+ }
+ };
+ vkbNoteOnRef.current = vkbNoteOn;
+ vkbNoteOffRef.current = vkbNoteOff;
+ window.__toggleVkb = function () {
+ setVkbOpen(function (p) {
+ const next = !p;
+ if (!next && vkbHeldRef.current.size) {
+ Array.from(vkbHeldRef.current).forEach(function (pitch) { try { vkbNoteOffRef.current(pitch); } catch (e) {} });
+ }
+ return next;
+ });
+ };
+
// ── Server-side upload ──
// WAV encoder tối thiểu — upload buffer-only clip khi LƯU (nếu chưa có
// serverFileId — upload sớm thất bại/race → clip mất sau reload).
@@ -29551,6 +29824,11 @@ STRICT CONSTRAINTS:
openPanel('python_tools');
}
}
+ }, {
+ label: 'Virtual MIDI Keyboard',
+ icon: 'piano',
+ shortcut: 'F2',
+ action: () => window.__toggleVkb()
}, {
sep: true
}, {
@@ -32685,6 +32963,14 @@ STRICT CONSTRAINTS:
track: (activeTracksRef.current || []).find(t => t.id === fxRackTarget.trackId) || tracks.find(t => t.id === fxRackTarget.trackId) || null,
onUpdateTrack: updateTrackProp,
onClose: () => setFxRackTarget(null)
+ }), vkbOpen && /*#__PURE__*/React.createElement(VirtualMidiKeyboard, {
+ settings: vkbSettings,
+ onChange: vkbUpdateSettings,
+ tracks: activeTracks,
+ activePitches: activeMidiPitches,
+ onNoteOn: vkbNoteOn,
+ onNoteOff: vkbNoteOff,
+ onClose: () => window.__toggleVkb()
}), /*#__PURE__*/React.createElement(ExportModal, {
open: showExportPanel,
onClose: () => setShowExportPanel(false),
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index f2b80af..5e73122 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -942,7 +942,11 @@ if(next&&st.source.buffer){if(hasSelection){st.source.loopStart=Math.min(sStart,
if(cur&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();stopAllNativeVstNotes();playMidiPreview(cur,token);}}return next;});};const toggleRate=dir=>setRate(prev=>Math.max(0.25,Math.min(4.0,Math.round((prev+dir*0.1)*100)/100)));const togglePitch=dir=>setPitch(prev=>Math.max(-24,Math.min(24,prev+dir*0.5)));const selIsMidi=isMidiFile(selected);const selDur=fileDuration(selected);const selBpm=selected&&selected.bpm||tempo;return/*#__PURE__*/React.createElement("div",{ref:containerRef,className:"flex flex-col w-full h-full text-slate-900 overflow-hidden select-none",style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 flex-1 mr-2"},/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Back",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-left"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Forward",onClick:()=>setFolder('uploads')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-right"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Up Directory",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-up"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Refresh",onClick:()=>{if(folder==='computer'){const node=computerTree[computerPath];if(node&&node.handle)browseComputerDir({name:computerPath.split('/').pop()||computerPath,path:computerPath,is_dir:true,handle:node.handle});else if(computerPath)browseComputerDir({name:computerPath.split(/[\\/]/).pop()||computerPath,path:computerPath,is_dir:true});}else if(window.SonicAPI&&window.SonicAPI.listMyFiles)window.SonicAPI.listMyFiles([]).then(d=>setUserFiles(d||[]));}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-rotate-right"})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center bg-white border border-[#808080] h-5 px-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"}),/*#__PURE__*/React.createElement("span",{className:"flex-1 text-xs text-slate-800 truncate"},folder==='computer'?computerPath||'My Computer':folder==='library'?'Media Library':folder==='uploads'?'Uploads':'Processed'),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center bg-white border border-[#808080] h-5 px-1 w-40"},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Filter/Search...",value:filterText,onChange:e=>setFilterText(e.target.value),className:"w-full text-xs outline-none bg-transparent font-sans text-slate-800"}),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"})),/*#__PURE__*/React.createElement("button",{className:"px-2 py-0.5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold",onClick:()=>setViewMode(viewMode==='details'?'list':'details')},/*#__PURE__*/React.createElement("span",null,viewMode==='details'?'Details':'List')," ",/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-[9px]"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative"},/*#__PURE__*/React.createElement("div",{className:"flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden"},/*#__PURE__*/React.createElement("div",{ref:treePaneRef,className:"overflow-y-auto p-1",style:{width:treeWidth+'px',minWidth:treeWidth+'px',maxWidth:treeWidth+'px'}},/*#__PURE__*/React.createElement("div",{className:"space-y-0.5 font-sans"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 px-1 py-0.5 text-slate-700"},/*#__PURE__*/React.createElement("span",{className:"w-3"}),"