feat: Media Explorer - auto tempo theo MIDI file + gõ tempo tay (tempoText/commitTempo) + focus folder cha giữa tree + điều hướng tree bằng phím mũi tên

This commit is contained in:
2026-08-03 12:46:34 +07:00
parent 7325fbfc45
commit 6f55d36085
4 changed files with 190 additions and 23 deletions
+163 -13
View File
@@ -9170,6 +9170,10 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120;
});
const [tempoText, setTempoText] = React.useState(String(function() {
var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120;
}()));
const [zoom, setZoom] = React.useState(1.0);
const [scrollOffset, setScrollOffset] = React.useState(0);
@@ -9216,11 +9220,22 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
selStartRef.current = selStart;
selEndRef.current = selEnd;
isLoopingRef.current = isLooping;
const computerPathRef = React.useRef(null);
const computerTreeRef = React.useRef({});
const computerRootsRef = React.useRef(null);
const treePaneRef = React.useRef(null);
const browseComputerDirRef = React.useRef(null);
React.useEffect(() => {
setScrollOffset(0);
}, [selected]);
// Keep the tempo text field in sync when tempo changes from elsewhere
// (e.g. auto-set from a clicked MIDI file).
React.useEffect(() => {
setTempoText(String(tempo));
}, [tempo]);
React.useEffect(() => {
window.mediaExplorerActive = true;
const handleDocumentClick = (e) => {
@@ -9476,6 +9491,56 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
}
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
if (window.mediaExplorerActive && folderRef.current === 'computer') {
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
e.preventDefault();
e.stopPropagation();
// Build the flat list of visible (expanded) tree nodes.
const paths = [];
const walk = (entry, depth) => {
if (!entry || !entry.path) return;
paths.push({ path: entry.path, entry, depth });
const n = computerTreeRef.current[entry.path];
if (n && n.expanded && n.dirs) (n.dirs || []).forEach(d => walk(d, depth + 1));
};
(computerRootsRef.current || []).forEach(r => walk(r, 0));
if (!paths.length) return;
const curPath = computerPathRef.current;
let idx = paths.findIndex(p => p.path === curPath);
if (e.key === 'ArrowUp') {
const ni = idx < 0 ? paths.length - 1 : Math.max(0, idx - 1);
navigateTreeTo(paths[ni].path);
} else if (e.key === 'ArrowDown') {
const ni = Math.min(paths.length - 1, (idx < 0 ? -1 : idx) + 1);
navigateTreeTo(paths[ni].path);
} else if (e.key === 'ArrowRight') {
const node = computerTreeRef.current[curPath];
if (node && !node.expanded) {
setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: true } }));
if (!node.dirs || !node.dirs.length) {
if (browseComputerDirRef.current) browseComputerDirRef.current({ name: String(curPath).split('/').pop() || curPath, path: curPath, is_dir: true, handle: node.handle });
}
} else if (idx < 0) {
const p0 = paths[0];
if (p0) navigateTreeTo(p0.path);
}
} else if (e.key === 'ArrowLeft') {
const node = computerTreeRef.current[curPath];
if (node && node.expanded) {
setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: false } }));
} else if (curPath) {
const i = curPath.lastIndexOf('/');
if (i > 0) {
navigateTreeTo(curPath.substring(0, i));
}
}
}
}
}
if (e.key === 'c' && (e.ctrlKey || e.metaKey)) {
if (window.mediaExplorerActive) {
const target = e.target;
@@ -9502,6 +9567,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const [computerFiles, setComputerFiles] = React.useState([]);
const [computerMode, setComputerMode] = React.useState('server');
const [clientRoot, setClientRoot] = React.useState(null);
computerPathRef.current = computerPath;
computerTreeRef.current = computerTree;
computerRootsRef.current = computerRoots;
const [favorites, setFavorites] = React.useState(function() {
try { return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1') || '[]'); } catch (e) { return []; }
}());
@@ -9921,6 +9989,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
return null;
}
};
browseComputerDirRef.current = browseComputerDir;
const toggleComputerDir = async (entry) => {
if (!entry) return;
@@ -10116,6 +10185,14 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
if (!midiResult || !midiResult.length) return;
// Feature: selecting/playing a MIDI file auto-sets the playback tempo to
// the file's own BPM (so the preview plays in time).
const fileBpm = (midiResult[0] && midiResult[0].bpm) || 120;
const newTempo = Math.max(40, Math.min(300, Math.round(fileBpm)));
setTempo(newTempo);
setTempoText(String(newTempo));
tempoRef.current = newTempo;
try { localStorage.setItem('studio_media_explorer_tempo', String(newTempo)); } catch (e) {}
const ctx = getAudioContext();
const bpmVal = tempoRef.current || 120;
const secondsPerBeat = 60.0 / bpmVal;
@@ -10491,16 +10568,94 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying, zoom, selStart, selEnd, scrollOffset]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const commitTempo = (v) => {
const clamped = Math.max(40, Math.min(300, Math.round(v) || 120));
setTempo(clamped);
setTempoText(String(clamped));
tempoRef.current = clamped;
try { localStorage.setItem('studio_media_explorer_tempo', String(clamped)); } catch (e) {}
// Re-schedule a currently playing MIDI preview at the new tempo.
const cur = selectedRef.current;
if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId)) {
selectTokenRef.current++;
const token = selectTokenRef.current;
try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
playMidiPreview(cur, token);
}
};
// Center a folder node vertically in the tree pane (feature: file click focuses
// its parent folder in the middle of the tree).
const centerTreeNodeInPane = (path) => {
setTimeout(() => {
const pane = treePaneRef.current;
if (!pane) return;
const el = document.querySelector(`[data-tree-path="${path}"]`);
if (!el) return;
const paneRect = pane.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const target = pane.scrollTop + (elRect.top - paneRect.top) - paneRect.height / 2 + elRect.height / 2;
pane.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}, 80);
};
// Flat list of currently visible (expanded) tree nodes, in render order.
const getVisibleTreePaths = () => {
const out = [];
const walk = (entry, depth) => {
if (!entry || !entry.path) return;
out.push({ path: entry.path, entry, depth });
const node = computerTreeRef.current[entry.path];
if (node && node.expanded && node.dirs) {
(node.dirs || []).forEach(d => walk(d, depth + 1));
}
};
(computerRootsRef.current || []).forEach(r => walk(r, 0));
return out;
};
const navigateTreeTo = (path) => {
const paths = getVisibleTreePaths();
const target = paths.find(p => p.path === path);
if (!target) return;
// Expand all ancestor nodes so the target is visible in the tree.
setComputerTree(prev => {
const next = { ...prev };
let current = path;
while (current) {
const n = next[current];
if (n) next[current] = { ...n, expanded: true };
const idx = current.lastIndexOf('/');
if (idx <= 0) break;
current = current.substring(0, idx);
}
return next;
});
setComputerPath(path);
if (browseComputerDirRef.current) browseComputerDirRef.current(target.entry);
centerTreeNodeInPane(path);
};
const handleSelect = (f) => {
if (!f || f.is_dir) return;
// When a MIDI file is clicked, automatically set the play tempo to the
// MIDI file's tempo (BPM from metadata, or after parsing in playMidiPreview).
if (isMidiFile(f) && f.bpm) {
const v = Math.max(40, Math.min(300, Math.round(parseFloat(f.bpm) || 120)));
setTempo(v);
setTempoText(String(v));
tempoRef.current = v;
try { localStorage.setItem('studio_media_explorer_tempo', String(v)); } catch (e) {}
}
// Find parent path of selected file and scroll it into view in Tree pane
if (f.path) {
const lastSlash = f.path.lastIndexOf('/');
if (lastSlash > 0) {
const parentPath = f.path.substring(0, lastSlash);
setComputerPath(parentPath);
// Expand all parent nodes in computerTree
setComputerTree(prev => {
const next = { ...prev };
@@ -10518,13 +10673,8 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
return next;
});
// Scroll the parent tree element into view
setTimeout(() => {
const treeNodeEl = document.querySelector(`[data-tree-path="${parentPath}"]`);
if (treeNodeEl) {
treeNodeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
// Center the parent folder node in the middle of the tree pane
centerTreeNodeInPane(parentPath);
}
}
@@ -10652,7 +10802,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
<div className="flex-1 flex overflow-hidden min-h-0 relative">
{/* DIRECTORY TREE */}
<div className="flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden">
<div className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}>
<div ref={treePaneRef} className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}>
<div className="space-y-0.5 font-sans">
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Track Templates&gt;</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Project Directory&gt;</div>
@@ -10870,9 +11020,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
</div>
<div className="flex items-center gap-1" title="Tempo preview MIDI">
<span className="font-mono text-[10px] text-slate-700">Tempo:</span>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => { const nt = Math.max(40, tempo - 1); setTempo(nt); localStorage.setItem('studio_media_explorer_tempo', nt.toString()); }}>-</button>
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-11"><input type="number" min="40" max="300" value={tempo} onChange={e => { const v = Math.max(40, Math.min(300, parseInt(e.target.value) || 120)); setTempo(v); localStorage.setItem('studio_media_explorer_tempo', v.toString()); }} className="w-full text-xs text-right outline-none bg-transparent" /></div>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => { const nt = Math.min(300, tempo + 1); setTempo(nt); localStorage.setItem('studio_media_explorer_tempo', nt.toString()); }}>+</button>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => commitTempo((tempo || 120) - 1)}>-</button>
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-14"><input type="number" min="40" max="300" value={tempoText} onChange={e => { const raw = e.target.value; setTempoText(raw); const n = parseInt(raw); if (n >= 40 && n <= 300) commitTempo(n); }} onBlur={() => { const n = parseInt(tempoText); commitTempo(n); }} onKeyDown={e => { if (e.key === 'Enter') { e.currentTarget.blur(); } }} className="w-full text-xs text-right outline-none bg-transparent" /></div>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => commitTempo((tempo || 120) + 1)}>+</button>
<span className="text-slate-600 text-[10px]">BPM</span>
</div>
</div>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,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=202608031415" defer></script>
<script src="/static/js/app.precompiled.js?v=202608031430" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+5
View File
@@ -1274,3 +1274,8 @@
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.
### [2026-08-03 14:30] Task: Media Explorer - auto tempo theo MIDI file, gõ tempo tay, focus folder cha, điều hướng tree bằng phím mũi tên
- **Tóm tắt thay đổi:** (1) **Auto set tempo**: click MIDI file → `handleSelect` set tempo từ metadata `f.bpm`; `playMidiPreview` sau khi parse set tempo theo `midiResult[0].bpm` (clamp 40-300) trước khi schedule → preview phát đúng tempo file. (2) **Gõ tempo tay**: input tempo dùng `tempoText` (string) cho phép gõ tự do (trước đây clamp 40-300 ngay khi gõ chặn việc nhập số < 40), commit khi hợp lệ hoặc blur/Enter; `commitTempo` còn re-schedule MIDI preview đang phát theo tempo mới. (3) **Focus folder cha**: click file → expand các node cha + `centerTreeNodeInPane` cuộn tree pane (ref `treePaneRef`) để folder cha hiện GIỮA ô tree. (4) **Phím mũi tên**: khi panel active (`window.mediaExplorerActive`) và ở computer mode, ArrowUp/Down di chuyển cursor qua node hiển thị (dùng `computerPathRef`/`computerTreeRef`/`computerRootsRef` để tránh stale closure trong keydown `[]`), ArrowRight expand/load, ArrowLeft collapse hoặc về thư mục cha; `browseComputerDirRef` tránh stale `browseComputerDir`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa commitTempo/navigateTreeTo/centerTreeNodeInPane/getVisibleTreePaths/treePaneRef/tempoText. Harness `node /tmp/kilo/test_tree.js` mô phỏng flatten tree + Up/Down/Left logic — ALL PASSED. 9 harness còn lại PASS. Hard refresh.