Phase 5: Virtual MIDI Keyboard (F2/menu Tools) — QWERTY mapping spec §3, note routing theo attachMidiHandler (bridge/native SF-VST/SonicSF/Carla), live record forward, drag+persist localStorage, verify Playwright + 3 regression PASS

This commit is contained in:
2026-08-21 12:16:03 +07:00
parent 5fc45a59f8
commit ec85e7d1a9
4 changed files with 319 additions and 8 deletions
+13 -2
View File
@@ -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 && <VirtualMidiKeyboard settings tracks activePitches onNoteOn onNoteOff onClose/>` 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)
+286
View File
@@ -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),
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -50,7 +50,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608211133" defer></script>
<script src="/static/js/app.precompiled.js?v=202608211150" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {