feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path

This commit is contained in:
2026-08-02 18:01:34 +07:00
parent 249e2afea2
commit c09f994ede
4 changed files with 171 additions and 37 deletions
+147 -25
View File
@@ -9027,6 +9027,9 @@ const MediaExplorerPanel = ({ height }) => {
const [audioBuffer, setAudioBuffer] = React.useState(null);
const [midiNotes, setMidiNotes] = React.useState(null);
const [midiTotal, setMidiTotal] = React.useState(4);
const [midiBars, setMidiBars] = React.useState(1);
const [midiTotalBeats, setMidiTotalBeats] = React.useState(16);
const [midiFileBpm, setMidiFileBpm] = React.useState(120);
const [tempo, setTempo] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120;
@@ -9038,6 +9041,9 @@ const MediaExplorerPanel = ({ height }) => {
const audioBufferRef = React.useRef(null);
const midiNotesRef = React.useRef(null);
const midiTotalRef = React.useRef(4);
const midiBarsRef = React.useRef(1);
const midiTotalBeatsRef = React.useRef(16);
const midiFileBpmRef = React.useRef(120);
const isPlayingRef = React.useRef(false);
const isPausedRef = React.useRef(false);
const folderRef = React.useRef('library');
@@ -9048,6 +9054,9 @@ const MediaExplorerPanel = ({ height }) => {
audioBufferRef.current = audioBuffer;
midiNotesRef.current = midiNotes;
midiTotalRef.current = midiTotal;
midiBarsRef.current = midiBars;
midiTotalBeatsRef.current = midiTotalBeats;
midiFileBpmRef.current = midiFileBpm;
isPlayingRef.current = isPlaying;
isPausedRef.current = isPaused;
folderRef.current = folder;
@@ -9068,6 +9077,8 @@ const MediaExplorerPanel = ({ height }) => {
const [synthLoading, setSynthLoading] = React.useState(false);
const synthListRef = React.useRef(null);
synthListRef.current = synthList;
const synthInstRef = React.useRef(null);
synthInstRef.current = synthInst;
const [treeWidth, setTreeWidth] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_tree_width');
return saved ? parseInt(saved) : 176;
@@ -9094,6 +9105,7 @@ const MediaExplorerPanel = ({ height }) => {
const canvasRef = React.useRef(null);
const playStateRef = React.useRef(null);
const rafRef = React.useRef(null);
const loopTimerRef = React.useRef(null);
const selectTokenRef = React.useRef(0);
React.useEffect(() => {
@@ -9302,6 +9314,36 @@ const MediaExplorerPanel = ({ height }) => {
const openMyComputer = async () => {
setFolder('computer');
// Restore last opened folder from session (click My Computer show folder content directly)
try {
const raw = localStorage.getItem(SESSION_KEY);
if (raw) {
const snap = JSON.parse(raw);
if (snap && snap.computerPath && snap.computerFiles) {
setComputerPath(snap.computerPath);
setComputerFiles(snap.computerFiles);
setComputerMode(snap.computerMode || 'server');
if (snap.computerTree) {
const tree = {};
Object.keys(snap.computerTree).forEach(p => { tree[p] = { dirs: snap.computerTree[p].dirs || [], expanded: snap.computerTree[p].expanded }; });
setComputerTree(tree);
}
if (snap.computerRoots && snap.computerRoots.length) setComputerRoots(snap.computerRoots);
if (snap.selected) setSelected(snap.selected);
// For client mode, re-attach real handles by walking from the stored root handle
if (snap.computerMode === 'client') {
const savedHandle = await loadClientRootHandle();
if (savedHandle && savedHandle.kind === 'directory') {
setClientRoot(savedHandle);
setComputerRoots([{ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle }]);
setComputerMode('client');
browseComputerDir({ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle });
}
}
return;
}
}
} catch (e) {}
if (window.showDirectoryPicker) {
try {
const handle = await window.showDirectoryPicker({ mode: 'read' });
@@ -9470,8 +9512,17 @@ const MediaExplorerPanel = ({ height }) => {
const selectSynthInst = (inst) => {
setSynthInst(inst);
synthInstRef.current = inst;
setSynthOpen(false);
localStorage.setItem('studio_media_explorer_synth', JSON.stringify(inst));
// Realtime: re-schedule current MIDI preview with the newly selected instrument
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);
}
};
const playMidiPreview = async (f, token) => {
@@ -9484,31 +9535,54 @@ const MediaExplorerPanel = ({ height }) => {
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
if (!midiResult || !midiResult.length) return;
const ctx = getAudioContext();
const bpmVal = tempoRef.current || parseInt(synthInst && synthInst.bpm) || 120;
const bpmVal = tempoRef.current || 120;
const secondsPerBeat = 60.0 / bpmVal;
const startWallTime = ctx.currentTime + 0.05;
const program = synthInst ? synthInst.program : undefined;
const sfId = synthInst ? synthInst.sfId : undefined;
const bank = synthInst ? synthInst.bank : 0;
if (synthInst && window.SonicSF.selectInstrument) {
const curInst = synthInstRef.current;
const program = curInst ? curInst.program : undefined;
const sfId = curInst ? curInst.sfId : undefined;
const bank = curInst ? curInst.bank : 0;
if (curInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
}
// Re-check token after the async await stale playMidiPreview (older file)
// must not schedule notes over the newly selected file.
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
const prog = (synthInst && synthInst.program !== undefined) ? synthInst.program : undefined;
const eng = synthInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined;
const prog = (curInst && curInst.program !== undefined) ? curInst.program : undefined;
const eng = curInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined;
const totalSec = midiResult[0].duration || 4;
const allNotes = [];
midiResult.forEach(track => {
(track.notes || []).forEach(note => {
const startSec = (note.start_beat || 0) * secondsPerBeat;
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, startWallTime + startSec, prog, null, 0, eng);
allNotes.push(Object.assign({}, note, { trackOffset: track.startTime || 0 }));
});
});
const schedulePass = (passStartTime) => {
allNotes.forEach(note => {
const startSec = (note.start_beat || 0) * secondsPerBeat + (note.trackOffset || 0);
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, 0, eng);
});
};
schedulePass(startWallTime);
// Loop scheduling
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
if (isLooping) {
loopTimerRef.current = setInterval(() => {
if (selectTokenRef.current !== (token || selectTokenRef.current)) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
if (!isPlayingRef.current || isPausedRef.current) return;
const passStart = ctx.currentTime + 0.05;
schedulePass(passStart);
playStateRef.current = Object.assign({}, playStateRef.current, { startedAt: passStart, fakeStart: passStart });
}, Math.max(200, totalSec * 1000));
}
// Keep a fake clock so canvas playhead animates; loop uses playStateRef
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: midiResult[0].duration || 4 };
setMidiNotes(midiResult[0].notes || []);
setMidiTotal(midiResult[0].duration || 4);
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: totalSec };
setMidiNotes(allNotes);
setMidiTotal(totalSec);
setMidiBars(midiResult[0].bars || 1);
setMidiTotalBeats(midiResult[0].totalBeats || 16);
setMidiFileBpm(midiResult[0].bpm || 120);
setIsPlaying(true);
setIsPaused(false);
startCanvasClock();
@@ -9523,6 +9597,7 @@ const MediaExplorerPanel = ({ height }) => {
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') {
try { window.SonicSF.stopAll(); } catch (e) {}
}
@@ -9616,6 +9691,8 @@ const MediaExplorerPanel = ({ height }) => {
const curAudioBuffer = audioBufferRef.current;
const curMidiNotes = midiNotesRef.current;
const curMidiTotal = midiTotalRef.current;
const curMidiBars = midiBarsRef.current;
const curMidiTotalBeats = midiTotalBeatsRef.current;
const curTempo = tempoRef.current;
const playing = isPlayingRef.current;
if (!f) {
@@ -9631,14 +9708,17 @@ const MediaExplorerPanel = ({ height }) => {
// Content scrolls when wider than frame: playhead stays at frame center
const bpmV = curTempo || 120;
const pxPerBeat = 42;
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * bpmV / 60, 4) : (f.lengthQn || 16);
const beats = totalBeats;
const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
? curMidiTotalBeats
: Math.max(curMidiTotal * bpmV / 60, 4);
const beats = Math.max(totalBeats, 4);
const contentW = Math.max(w, beats * pxPerBeat);
const pxPerSec = pxPerBeat * bpmV / 60;
let offset = 0;
if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
}
// bar lines (every 4 beats) + beat labels
for (let b = 0; b <= beats; b += 4) {
const x = b * pxPerBeat - offset;
if (x < -10 || x > w + 10) continue;
@@ -9702,9 +9782,11 @@ const MediaExplorerPanel = ({ height }) => {
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
const rulerBpm = curTempo || 120;
const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm);
const rulerContentW = Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
const rulerTotalBeats = Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * (rulerPxPerBeat * rulerBpm / 60) - w / 2)) : 0;
const isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
const rulerContentW = isRealMidi ? Math.max(w, rulerTotalBeats * rulerPxPerBeat) : Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
const rulerPxPerSec = rulerPxPerBeat * rulerBpm / 60;
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * rulerPxPerSec - w / 2)) : 0;
for (let b = 0; b <= rulerTotalBeats; b += 4) {
const x = b * rulerPxPerBeat - rulerOffset;
if (x < -20 || x > w + 20) continue;
@@ -9755,6 +9837,16 @@ const MediaExplorerPanel = ({ height }) => {
setIsLooping(prev => {
const next = !prev;
if (playStateRef.current && playStateRef.current.source) playStateRef.current.source.loop = next;
if (next && isMidiFile(selectedRef.current)) {
// Re-schedule loop for the currently previewing MIDI file
const cur = selectedRef.current;
if (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);
}
}
return next;
});
};
@@ -9972,9 +10064,20 @@ const MediaExplorerPanel = ({ height }) => {
) : (
<React.Fragment>
<div>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div>
<div>Duration: {selDur.toFixed(2)}s</div>
<div>Sample Rate: 44100 Hz</div>
<div>Type: {selected.path ? (selIsMidi ? 'Local MIDI' : (selected.kind === 'other' ? 'Local File' : 'Local Audio')) : (selected.type || 'Audio')}</div>
{selIsMidi ? (
<React.Fragment>
<div>Bars: {midiBars || 1}</div>
<div>Beats: {Math.round(midiTotalBeats || 16)}</div>
<div>BPM: {midiFileBpm || 120}</div>
<div>Duration: {selDur.toFixed(2)}s</div>
</React.Fragment>
) : (
<React.Fragment>
<div>Duration: {selDur.toFixed(2)}s</div>
<div>Sample Rate: 44100 Hz</div>
<div>Type: {selected.path ? (selected.kind === 'other' ? 'Local File' : 'Local Audio') : (selected.type || 'Audio')}</div>
</React.Fragment>
)}
</React.Fragment>
)
) : <div>No file selected</div>}
@@ -9984,7 +10087,14 @@ const MediaExplorerPanel = ({ height }) => {
{/* FOOTER STATUS BAR */}
<div className="h-5 bg-[#c0c0c0] border-t border-white flex items-center justify-between text-[11px] font-mono px-1 shrink-0">
<div className="flex items-center gap-3">
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">{formatTime(currentTime)} / {formatTime(selDur)}</div>
{selIsMidi && midiNotes && midiNotes.length ? (
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">
Bar {Math.max(1, Math.floor(currentTime / (4 * 60 / (tempo || 120))) + 1)} / {midiBars || 1}
<span className="text-slate-500 ml-1">| {formatTime(currentTime)} / {formatTime(selDur)}</span>
</div>
) : (
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">{formatTime(currentTime)} / {formatTime(selDur)}</div>
)}
</div>
<div className="text-slate-800 font-bold truncate max-w-[40%]">{selected ? (selected.name || selected.original_name) : 'No file selected'}</div>
<div className="text-slate-700">{selBpm} bpm x{rate.toFixed(2)}</div>
@@ -16738,9 +16848,21 @@ const App = () => {
midiNotes.push({ id: 'mn_' + t + '_' + p + '_' + pn.tick, pitch: p, start_beat: pn.tick / ticksPerBeat, duration_beats: dur / ticksPerBeat, velocity: Math.min(1, pn.vel / 127) });
});
if (midiNotes.length > 0) {
var lastEnd = 0;
midiNotes.forEach(function(n) { var e = (n.start_beat + n.duration_beats) * 60 / bpm; if (e > lastEnd) lastEnd = e; });
result.push({ name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0, id: 'midi_' + t + '_' + Date.now() });
var lastEnd = 0; var maxEndBeat = 0;
midiNotes.forEach(function(n) {
var e = (n.start_beat + n.duration_beats) * 60 / bpm;
if (e > lastEnd) lastEnd = e;
var eb = n.start_beat + n.duration_beats;
if (eb > maxEndBeat) maxEndBeat = eb;
});
result.push({
name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0,
id: 'midi_' + t + '_' + Date.now(),
totalBeats: maxEndBeat || 16,
bars: Math.max(1, Math.ceil((maxEndBeat || 16) / 4)),
bpm: bpm,
ticksPerBeat: ticksPerBeat
});
}
pos = endPos;
}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -8,6 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
@@ -23,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=202608021805" defer></script>
<script src="/static/js/app.precompiled.js?v=202608021850" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {