fix: canvas MIDI lần đầu + thêm Tempo control preview
This commit is contained in:
+59
-17
@@ -9027,6 +9027,32 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const [audioBuffer, setAudioBuffer] = React.useState(null);
|
||||
const [midiNotes, setMidiNotes] = React.useState(null);
|
||||
const [midiTotal, setMidiTotal] = React.useState(4);
|
||||
const [tempo, setTempo] = React.useState(function() {
|
||||
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
||||
return saved ? parseInt(saved) : 120;
|
||||
}());
|
||||
// Refs mirror latest state so drawCanvas (also called from rAF clock with a
|
||||
// stale closure) always draws the currently selected file, not the old one.
|
||||
const selectedRef = React.useRef(null);
|
||||
const peaksRef = React.useRef(null);
|
||||
const audioBufferRef = React.useRef(null);
|
||||
const midiNotesRef = React.useRef(null);
|
||||
const midiTotalRef = React.useRef(4);
|
||||
const isPlayingRef = React.useRef(false);
|
||||
const isPausedRef = React.useRef(false);
|
||||
const folderRef = React.useRef('library');
|
||||
const tempoRef = React.useRef(120);
|
||||
const currentTimeRef = React.useRef(0);
|
||||
selectedRef.current = selected;
|
||||
peaksRef.current = peaks;
|
||||
audioBufferRef.current = audioBuffer;
|
||||
midiNotesRef.current = midiNotes;
|
||||
midiTotalRef.current = midiTotal;
|
||||
isPlayingRef.current = isPlaying;
|
||||
isPausedRef.current = isPaused;
|
||||
folderRef.current = folder;
|
||||
tempoRef.current = tempo;
|
||||
currentTimeRef.current = currentTime;
|
||||
const [computerRoots, setComputerRoots] = React.useState(null);
|
||||
const [computerTree, setComputerTree] = React.useState({});
|
||||
const [computerPath, setComputerPath] = React.useState(null);
|
||||
@@ -9082,11 +9108,12 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const fileDuration = f => {
|
||||
if (!f) return 0;
|
||||
if (isMidiFile(f)) {
|
||||
if (midiTotal && midiNotes && midiNotes.length) return midiTotal;
|
||||
return (f.lengthQn || 16) * 60 / (f.bpm || 120);
|
||||
if (midiTotalRef.current && midiNotesRef.current && midiNotesRef.current.length) return midiTotalRef.current;
|
||||
return (f.lengthQn || 16) * 60 / (f.bpm || tempoRef.current || 120);
|
||||
}
|
||||
const matches = selected && ((f.path && f.path === selected.path) || (!f.path && (f.file_id || f.fileId) === (selected.file_id || selected.fileId)));
|
||||
return f.duration || (audioBuffer && matches ? audioBuffer.duration : 0) || 0;
|
||||
const sel = selectedRef.current;
|
||||
const matches = sel && ((f.path && f.path === sel.path) || (!f.path && (f.file_id || f.fileId) === (sel.file_id || sel.fileId)));
|
||||
return f.duration || (audioBufferRef.current && matches ? audioBufferRef.current.duration : 0) || 0;
|
||||
};
|
||||
|
||||
const folderFiles = React.useMemo(() => {
|
||||
@@ -9325,7 +9352,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
|
||||
if (!midiResult || !midiResult.length) return;
|
||||
const ctx = getAudioContext();
|
||||
const bpmVal = parseInt(synthInst && synthInst.bpm) || 120;
|
||||
const bpmVal = tempoRef.current || parseInt(synthInst && synthInst.bpm) || 120;
|
||||
const secondsPerBeat = 60.0 / bpmVal;
|
||||
const startWallTime = ctx.currentTime + 0.05;
|
||||
const program = synthInst ? synthInst.program : undefined;
|
||||
@@ -9429,12 +9456,13 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
const tick = () => {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
let t = currentTime;
|
||||
let t = currentTimeRef.current;
|
||||
const st = playStateRef.current;
|
||||
if (st && !isPaused) {
|
||||
if (st && !isPausedRef.current) {
|
||||
t = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
|
||||
}
|
||||
setCurrentTime(t);
|
||||
currentTimeRef.current = t;
|
||||
drawCanvas(t);
|
||||
};
|
||||
tick();
|
||||
@@ -9450,7 +9478,14 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
ctx.scale(2, 2);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h);
|
||||
const f = selected;
|
||||
// Read from refs so the rAF clock's stale closure still draws the latest file
|
||||
const f = selectedRef.current;
|
||||
const curPeaks = peaksRef.current;
|
||||
const curAudioBuffer = audioBufferRef.current;
|
||||
const curMidiNotes = midiNotesRef.current;
|
||||
const curMidiTotal = midiTotalRef.current;
|
||||
const curTempo = tempoRef.current;
|
||||
const playing = isPlayingRef.current;
|
||||
if (!f) {
|
||||
ctx.fillStyle = '#555'; ctx.font = '12px JetBrains Mono, monospace';
|
||||
ctx.fillText('No file selected', 10, h / 2);
|
||||
@@ -9461,18 +9496,18 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
ctx.strokeStyle = '#333';
|
||||
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
ctx.strokeStyle = '#444';
|
||||
const totalBeats = midiNotes && midiNotes.length ? Math.max(midiTotal * (f.bpm || 120) / 60, 4) : (f.lengthQn || 16);
|
||||
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * (curTempo || 120) / 60, 4) : (f.lengthQn || 16);
|
||||
const beats = totalBeats;
|
||||
for (let b = 0; b <= beats; b += 4) {
|
||||
const x = (b / beats) * w;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
if (midiNotes && midiNotes.length) {
|
||||
if (curMidiNotes && curMidiNotes.length) {
|
||||
// Real piano-roll: rows = pitches (48..84), columns = beats
|
||||
const pitchMin = 48, pitchMax = 84;
|
||||
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
||||
midiNotes.forEach(n => {
|
||||
curMidiNotes.forEach(n => {
|
||||
const x = (n.start_beat / beats) * w;
|
||||
const nw = Math.max(3, (n.duration_beats / beats) * w);
|
||||
const y = h - 14 - 8 - (((Math.min(pitchMax, Math.max(pitchMin, n.pitch)) - pitchMin) / pitchRange) * (h - 30));
|
||||
@@ -9487,7 +9522,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pk = peaks && peaks.length > 0 ? peaks : null;
|
||||
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
|
||||
if (pk) {
|
||||
const mid = h / 2;
|
||||
ctx.fillStyle = '#22c55e';
|
||||
@@ -9504,13 +9539,13 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
// ruler
|
||||
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
||||
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
||||
const totalBeats = Math.max(4, Math.ceil(dur * (f.bpm || 120) / 60) || 16);
|
||||
for (let b = 0; b <= totalBeats; b += 4) {
|
||||
const x = (b / totalBeats) * w;
|
||||
const rulerBeats = Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16);
|
||||
for (let b = 0; b <= rulerBeats; b += 4) {
|
||||
const x = (b / rulerBeats) * w;
|
||||
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
|
||||
}
|
||||
// playhead
|
||||
if (isPlaying && dur > 0) {
|
||||
if (playing && dur > 0) {
|
||||
const px = (t / dur) * w;
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(px, 0, 2, h - 14);
|
||||
@@ -9569,7 +9604,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
|
||||
const selIsMidi = isMidiFile(selected);
|
||||
const selDur = fileDuration(selected);
|
||||
const selBpm = selected && (selected.bpm || 120);
|
||||
const selBpm = (selected && selected.bpm) || tempo;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
@@ -9728,6 +9763,13 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<span className="text-slate-600 text-[10px]">BPM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 font-mono text-[11px]">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user