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 [audioBuffer, setAudioBuffer] = React.useState(null);
|
||||||
const [midiNotes, setMidiNotes] = React.useState(null);
|
const [midiNotes, setMidiNotes] = React.useState(null);
|
||||||
const [midiTotal, setMidiTotal] = React.useState(4);
|
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 [computerRoots, setComputerRoots] = React.useState(null);
|
||||||
const [computerTree, setComputerTree] = React.useState({});
|
const [computerTree, setComputerTree] = React.useState({});
|
||||||
const [computerPath, setComputerPath] = React.useState(null);
|
const [computerPath, setComputerPath] = React.useState(null);
|
||||||
@@ -9082,11 +9108,12 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const fileDuration = f => {
|
const fileDuration = f => {
|
||||||
if (!f) return 0;
|
if (!f) return 0;
|
||||||
if (isMidiFile(f)) {
|
if (isMidiFile(f)) {
|
||||||
if (midiTotal && midiNotes && midiNotes.length) return midiTotal;
|
if (midiTotalRef.current && midiNotesRef.current && midiNotesRef.current.length) return midiTotalRef.current;
|
||||||
return (f.lengthQn || 16) * 60 / (f.bpm || 120);
|
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)));
|
const sel = selectedRef.current;
|
||||||
return f.duration || (audioBuffer && matches ? audioBuffer.duration : 0) || 0;
|
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(() => {
|
const folderFiles = React.useMemo(() => {
|
||||||
@@ -9325,7 +9352,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
|
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
|
||||||
if (!midiResult || !midiResult.length) return;
|
if (!midiResult || !midiResult.length) return;
|
||||||
const ctx = getAudioContext();
|
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 secondsPerBeat = 60.0 / bpmVal;
|
||||||
const startWallTime = ctx.currentTime + 0.05;
|
const startWallTime = ctx.currentTime + 0.05;
|
||||||
const program = synthInst ? synthInst.program : undefined;
|
const program = synthInst ? synthInst.program : undefined;
|
||||||
@@ -9429,12 +9456,13 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
let t = currentTime;
|
let t = currentTimeRef.current;
|
||||||
const st = playStateRef.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;
|
t = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
|
||||||
}
|
}
|
||||||
setCurrentTime(t);
|
setCurrentTime(t);
|
||||||
|
currentTimeRef.current = t;
|
||||||
drawCanvas(t);
|
drawCanvas(t);
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
@@ -9450,7 +9478,14 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
ctx.scale(2, 2);
|
ctx.scale(2, 2);
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.fillStyle = '#181818'; ctx.fillRect(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) {
|
if (!f) {
|
||||||
ctx.fillStyle = '#555'; ctx.font = '12px JetBrains Mono, monospace';
|
ctx.fillStyle = '#555'; ctx.font = '12px JetBrains Mono, monospace';
|
||||||
ctx.fillText('No file selected', 10, h / 2);
|
ctx.fillText('No file selected', 10, h / 2);
|
||||||
@@ -9461,18 +9496,18 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
ctx.strokeStyle = '#333';
|
ctx.strokeStyle = '#333';
|
||||||
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||||
ctx.strokeStyle = '#444';
|
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;
|
const beats = totalBeats;
|
||||||
for (let b = 0; b <= beats; b += 4) {
|
for (let b = 0; b <= beats; b += 4) {
|
||||||
const x = (b / beats) * w;
|
const x = (b / beats) * w;
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
||||||
}
|
}
|
||||||
ctx.fillStyle = '#9ca3af';
|
ctx.fillStyle = '#9ca3af';
|
||||||
if (midiNotes && midiNotes.length) {
|
if (curMidiNotes && curMidiNotes.length) {
|
||||||
// Real piano-roll: rows = pitches (48..84), columns = beats
|
// Real piano-roll: rows = pitches (48..84), columns = beats
|
||||||
const pitchMin = 48, pitchMax = 84;
|
const pitchMin = 48, pitchMax = 84;
|
||||||
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
||||||
midiNotes.forEach(n => {
|
curMidiNotes.forEach(n => {
|
||||||
const x = (n.start_beat / beats) * w;
|
const x = (n.start_beat / beats) * w;
|
||||||
const nw = Math.max(3, (n.duration_beats / 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));
|
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 {
|
} else {
|
||||||
const pk = peaks && peaks.length > 0 ? peaks : null;
|
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
|
||||||
if (pk) {
|
if (pk) {
|
||||||
const mid = h / 2;
|
const mid = h / 2;
|
||||||
ctx.fillStyle = '#22c55e';
|
ctx.fillStyle = '#22c55e';
|
||||||
@@ -9504,13 +9539,13 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
// ruler
|
// ruler
|
||||||
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
||||||
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
||||||
const totalBeats = Math.max(4, Math.ceil(dur * (f.bpm || 120) / 60) || 16);
|
const rulerBeats = Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16);
|
||||||
for (let b = 0; b <= totalBeats; b += 4) {
|
for (let b = 0; b <= rulerBeats; b += 4) {
|
||||||
const x = (b / totalBeats) * w;
|
const x = (b / rulerBeats) * w;
|
||||||
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
|
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
|
||||||
}
|
}
|
||||||
// playhead
|
// playhead
|
||||||
if (isPlaying && dur > 0) {
|
if (playing && dur > 0) {
|
||||||
const px = (t / dur) * w;
|
const px = (t / dur) * w;
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
ctx.fillRect(px, 0, 2, h - 14);
|
ctx.fillRect(px, 0, 2, h - 14);
|
||||||
@@ -9569,7 +9604,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
|
|
||||||
const selIsMidi = isMidiFile(selected);
|
const selIsMidi = isMidiFile(selected);
|
||||||
const selDur = fileDuration(selected);
|
const selDur = fileDuration(selected);
|
||||||
const selBpm = selected && (selected.bpm || 120);
|
const selBpm = (selected && selected.bpm) || tempo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<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>
|
</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>
|
||||||
<div className="flex items-center gap-3 font-mono text-[11px]">
|
<div className="flex items-center gap-3 font-mono text-[11px]">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<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/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608021724" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608021735" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1119,3 +1119,8 @@
|
|||||||
- **Tóm tắt thay đổi:** `playMidiPreview` (MediaExplorerPanel) thiếu re-check token sau `await window.SonicSF.selectInstrument(...)` — khi click file A rồi nhanh tới file B, invocation cũ (A) chờ selectInstrument xong rồi mới schedule notes, ghi đè lên file B mới chọn → auto-play nghe file cũ nhưng canvas hiển thị file mới. Thêm re-check `selectTokenRef.current !== token` ngay sau await (giống pattern đã có ở audio path sau `decodeAudioData`).
|
- **Tóm tắt thay đổi:** `playMidiPreview` (MediaExplorerPanel) thiếu re-check token sau `await window.SonicSF.selectInstrument(...)` — khi click file A rồi nhanh tới file B, invocation cũ (A) chờ selectInstrument xong rồi mới schedule notes, ghi đè lên file B mới chọn → auto-play nghe file cũ nhưng canvas hiển thị file mới. Thêm re-check `selectTokenRef.current !== token` ngay sau await (giống pattern đã có ở audio path sau `decodeAudioData`).
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản race: (1) A read chậm 200ms + B nhanh 10ms → chỉ B play; (2) A nhanh + B chậm → chỉ B play (A bị stopAll). Canvas luôn = file cuối click. Hard reload.
|
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản race: (1) A read chậm 200ms + B nhanh 10ms → chỉ B play; (2) A nhanh + B chậm → chỉ B play (A bị stopAll). Canvas luôn = file cuối click. Hard reload.
|
||||||
|
|
||||||
|
### [2026-08-02 17:35] Task: Fix canvas "No file selected" lần đầu + Tempo control
|
||||||
|
- **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload.
|
||||||
|
|||||||
Reference in New Issue
Block a user