5 Commits

4 changed files with 758 additions and 125 deletions
+554 -107
View File
@@ -7109,11 +7109,63 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
e.preventDefault(); e.preventDefault();
setSelectedNoteIds(notes.map(n => n.id)); setSelectedNoteIds(notes.map(n => n.id));
return;
}
// Spec 20:12 shortcuts: Alt+A Arp, Alt+S Strum, Alt+R Humanize,
// Shift+C chord stamp, Shift+S lock-scale toggle
const inField = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable);
// Ctrl+C/X/V copy/paste/cut NOTES (thay 2 nút Copy/Paste đã b
// user 08:40) CH tác đng NOTES ĐƯC CHN (user 09:10)
if ((e.ctrlKey || e.metaKey) && !e.altKey && !inField) {
if (e.key === 'c') {
e.preventDefault();
const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (sel.length) {
if (onCopyNotes) onCopyNotes(sel);
showToast('Đã copy ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Chọn notes trước khi copy (Ctrl+C).', 'warning'); }
return;
}
if (e.key === 'x') {
e.preventDefault();
const sel = notes.filter(n => selectedNoteIds.includes(n.id));
if (sel.length) {
if (onCopyNotes) onCopyNotes(sel);
pushToUndo(notes);
setNotes(prev => (prev || []).filter(n => !selectedNoteIds.includes(n.id)));
showToast('Đã cắt ' + sel.length + ' nốt được chọn.', 'success');
} else { showToast('Chọn notes trước khi cắt (Ctrl+X).', 'warning'); }
return;
}
if (e.key === 'v') {
e.preventDefault();
if (!clipboardNotes || !clipboardNotes.length) { showToast('Clipboard trống — bấm Ctrl+C trước.', 'warning'); return; }
pushToUndo(notes);
// Dán bt đu ti v trí CON TR PLAYHEAD (user 09:25) bù offset
// sao cho note đu tiên ca clipboard nm đúng playhead (beat).
const pasteBeat = (st.currentTime || 0) / (60.0 / (parseInt(bpm) || 120));
const minStart = Math.min(...clipboardNotes.map(n => n.start_beat));
const offset = pasteBeat - minStart;
setNotes(prev => [...(prev || []), ...clipboardNotes.map(function(n) {
return { ...n, id: 'note_cp_' + Date.now() + '_' + Math.floor(Math.random() * 100000), start_beat: Math.max(0, n.start_beat + offset) };
})]);
showToast('Đã paste ' + clipboardNotes.length + ' nốt tại playhead.', 'success');
return;
}
}
if (e.altKey && !e.ctrlKey && !inField && e.key === 'a') { e.preventDefault(); setArpModal({ pattern: 'UP', rate: '1/16', octaves: 2, gate: 80, triplet: false, dotted: false }); return; }
if (e.altKey && !e.ctrlKey && !inField && e.key === 's') { e.preventDefault(); setStrumModal({ ms: 30, direction: 'DOWN' }); return; }
if (e.altKey && !e.ctrlKey && !inField && e.key === 'r') { e.preventDefault(); setHumanizeModal({ timingMs: 12, velRange: 15, durRange: 10 }); return; }
if (e.shiftKey && !e.ctrlKey && !e.altKey && !inField && e.key === 'c') { e.preventDefault(); setChordStampMode(m => !m); return; }
if (e.shiftKey && !e.ctrlKey && !e.altKey && !inField && e.key === 's') {
e.preventDefault();
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s));
return;
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [notes, setSelectedNoteIds]); }, [notes, setSelectedNoteIds, clipboardNotes, selectedNoteIds]);
// Undo/redo stacks // Undo/redo stacks
const undoStackRef = React.useRef([]); const undoStackRef = React.useRef([]);
@@ -7390,11 +7442,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
canvas.height = h * dpr; canvas.height = h * dpr;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
// Scale highlight: rows thuc scale (root+scale) sáng vàng; ngoài scale
// dim hơn (spec 20:12 Scale Highlight & Lock)
const scHighlight = scaleWithRoot();
// Draw background rows // Draw background rows
for (let pitch = 0; pitch < 128; pitch++) { for (let pitch = 0; pitch < 128; pitch++) {
const y = (127 - pitch) * NoteHeight; const y = (127 - pitch) * NoteHeight;
const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12);
ctx.fillStyle = isBlack ? '#1a1a1e' : '#25252a'; const inScale = scHighlight ? scHighlight.includes(pitch % 12) : null;
ctx.fillStyle = isBlack ? (inScale === false ? '#141419' : '#1f1f25') : (inScale === false ? '#1b1b20' : (inScale === true ? '#2c2a1e' : '#25252a'));
ctx.fillRect(0, y, viewWidth, NoteHeight); ctx.fillRect(0, y, viewWidth, NoteHeight);
ctx.strokeStyle = '#2d2d35'; ctx.strokeStyle = '#2d2d35';
@@ -7569,6 +7625,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
} }
}, [notes, snapValue, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers, renderBeatOffset, renderTick, activeTracks, focusItemId]); }, [notes, snapValue, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers, renderBeatOffset, renderTick, activeTracks, focusItemId]);
// showCC/ccHeight khai báo TRƯC useLayoutEffect v CC (deps tham chiếu
// khai báo sau TDZ error user 08:50)
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const canvas = ccCanvasRef.current; const canvas = ccCanvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -7612,7 +7673,30 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
}); });
}, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset]); }, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]);
// Follow playhead: khi PLAY playhead luôn GIA view, notes trôi sang
// trái (scroll theo st.currentTime); khi STOP scroll v đu (playhead
// v trí đu piano roll) user 09:40. Dùng C isPlaying (main) LN
// st.isPlaying (piano roll play main isPlaying=false khi tab play bug
// 09:50: effect tưng đang stop luôn v đu).
React.useEffect(function() {
const wrapper = gridScrollRef.current;
if (!wrapper) return;
const playing = isPlaying || !!st.isPlaying;
if (!playing) {
if (wrapper.scrollLeft !== 0) { wrapper.scrollLeft = 0; setRenderTick(t => t + 1); }
return;
}
const beatSec = 60.0 / (parseInt(bpm) || 120);
const phBeat = (st.currentTime || 0) / beatSec;
const midX = Math.max(0, wrapper.clientWidth / 2);
const targetLeft = Math.max(0, phBeat * pixelsPerBeat - midX);
if (Math.abs(wrapper.scrollLeft - targetLeft) > 1) {
wrapper.scrollLeft = targetLeft;
}
setRenderTick(t => t + 1);
}, [isPlaying, st.isPlaying, st.currentTime]);
React.useEffect(() => { React.useEffect(() => {
const scrollToC3 = () => { const scrollToC3 = () => {
@@ -7701,8 +7785,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
} }
} }
// Ctrl+click: toggle selection (multi-select) // Shift+click: toggle selection (multi-select) user 09:30 (trưc đây là
if (e.ctrlKey && !e.altKey && !e.shiftKey) { // Ctrl+click Ctrl gi dành cho COPY-drag)
if (e.shiftKey && !e.altKey && !e.ctrlKey) {
if (clickedNoteIdx !== -1) { if (clickedNoteIdx !== -1) {
const clickedNote = notes[clickedNoteIdx]; const clickedNote = notes[clickedNoteIdx];
if (selectedNoteIds.includes(clickedNote.id)) { if (selectedNoteIds.includes(clickedNote.id)) {
@@ -7711,6 +7796,35 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
setSelectedNoteIds(prev => [...prev, clickedNote.id]); setSelectedNoteIds(prev => [...prev, clickedNote.id]);
} }
return; return;
}
}
// Ctrl+click NOTE + drag COPY nhanh nhóm notes/note đến v trí mi
// (cùng pitch ban đu; drag đi v trí + pitch) user 09:30
if (e.ctrlKey && !e.altKey && !e.shiftKey) {
if (clickedNoteIdx !== -1) {
const clickedNote = notes[clickedNoteIdx];
const groupIds = (selectedNoteIds.includes(clickedNote.id) && selectedNoteIds.length > 1)
? selectedNoteIds : [clickedNote.id];
const src = notes.filter(n => groupIds.includes(n.id));
if (!src.length) return;
pushToUndo(notes);
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
const clones = src.map(n => ({ ...JSON.parse(JSON.stringify(n)), id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8) }));
setNotes(prev => [...prev, ...clones]);
const cloneIds = clones.map(c => c.id);
setSelectedNoteIds(cloneIds);
const cloneOffsets = clones.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch }));
setDraggedNote({
mode: 'move',
idx: -1,
startOffsetBeat: beat - clickedNote.start_beat,
startOffsetPitch: pitch,
selectedNotesOffset: cloneOffsets,
clickedOriginalStartBeat: clickedNote.start_beat
});
showToast('Kéo để copy ' + clones.length + ' nốt.', 'info');
return;
} else { } else {
// Ctrl+click on a dim (same-track) MIDI note select it and focus its MIDI item // Ctrl+click on a dim (same-track) MIDI note select it and focus its MIDI item
var ctrlGhostHit = null; var ctrlGhostHit = null;
@@ -7883,6 +7997,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return; return;
} }
} }
// Chord stamp mode: click canvas stamp full chord shape (spec 20:12)
if (chordStampMode) {
const start = getSnapBeat(beat, snapValue);
applyChordStamp(start, snapToScaleRef.current ? snapPitchToScale(pitch, scaleWithRoot()) : pitch);
return;
}
// Click on empty space with pen tool: DRAW a new note (brush mode with visitedPitches) // Click on empty space with pen tool: DRAW a new note (brush mode with visitedPitches)
pushToUndo(notes); pushToUndo(notes);
const start = getSnapBeat(beat, snapValue); const start = getSnapBeat(beat, snapValue);
@@ -8182,6 +8302,19 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
} }
} }
setDraggedNote(null); setDraggedNote(null);
// Marquee (Ctrl+drag): CHN notes nm trong vùng (user 09:15 trưc đây
// ch v highlight, không set selectedNoteIds Ctrl+X báo "chn notes")
if (selectionMarquee) {
const minBeat = Math.min(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const maxBeat = Math.max(selectionMarquee.startBeat, selectionMarquee.currentBeat);
const minPitch = Math.min(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const maxPitch = Math.max(selectionMarquee.startPitch, selectionMarquee.currentPitch);
const inMarquee = notes.filter(n => {
const center = n.start_beat + n.duration_beats / 2;
return center >= minBeat && center <= maxBeat && n.pitch >= minPitch && n.pitch <= maxPitch;
});
setSelectedNoteIds(inMarquee.map(n => n.id));
}
setSelectionMarquee(null); setSelectionMarquee(null);
rightClickDragRef.current = { active: false, startX: 0, startY: 0 }; rightClickDragRef.current = { active: false, startX: 0, startY: 0 };
if (brushAutoScrollRef.current) { if (brushAutoScrollRef.current) {
@@ -8207,6 +8340,21 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const brushAutoScrollRef = React.useRef(null); const brushAutoScrollRef = React.useRef(null);
const rightClickDragRef = React.useRef({ active: false, startX: 0, startY: 0 }); const rightClickDragRef = React.useRef({ active: false, startX: 0, startY: 0 });
const swallowContextMenuRef = React.useRef(false); const swallowContextMenuRef = React.useRef(false);
// Status hint đng (user 09:40): theo dõi Shift/Ctrl + mouse trong piano roll
const prKeyStateRef = React.useRef({ shift: false, ctrl: false });
const prMouseInRef = React.useRef(false);
React.useEffect(function() {
const updateHint = function() {
if (!window.__setPrHint || !prMouseInRef.current) return;
const ks = prKeyStateRef.current;
window.__setPrHint(ks.ctrl ? "Ctrl+drag: fast copy notes | Ctrl+click empty: marquee chọn notes" : (ks.shift ? "Shift+Click: select/unselect notes | Shift+Click note: thêm/bớt vào nhóm chọn" : "Scroll: Up/Down | Drag: Draw notes"));
};
const kd = function(e) { const ks = prKeyStateRef.current; if (e.shiftKey !== ks.shift || e.ctrlKey !== ks.ctrl) { ks.shift = e.shiftKey; ks.ctrl = e.ctrlKey; updateHint(); } };
const ku = function(e) { const ks = prKeyStateRef.current; if (e.shiftKey !== ks.shift || e.ctrlKey !== ks.ctrl) { ks.shift = e.shiftKey; ks.ctrl = e.ctrlKey; updateHint(); } };
window.addEventListener('keydown', kd);
window.addEventListener('keyup', ku);
return function() { window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); };
}, []);
const findCCNoteIndex = (b, mouseY, ccH) => { const findCCNoteIndex = (b, mouseY, ccH) => {
const snapped = getSnapBeat(b, snapValue); const snapped = getSnapBeat(b, snapValue);
@@ -8232,6 +8380,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return nearest; return nearest;
}; };
// TT C notes bt đu ti cùng beat (chord velocity bars CÙNG ct)
// user 08:25: ctrl+draw qua v trí v li TT C velocity cùng v trí
// (trưc đây findCCNoteIndex ch tr 1 note chord khó draw).
const findCCNoteIndicesAtBeat = (b) => {
const snapped = getSnapBeat(b, snapValue);
const out = [];
notes.forEach((n, idx) => {
if (Math.abs(n.start_beat - snapped) < 0.01) out.push(idx);
});
return out;
};
const handleCCMouseDown = (e) => { const handleCCMouseDown = (e) => {
const canvas = ccCanvasRef.current; const canvas = ccCanvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -8264,7 +8424,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [] }; ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [] };
} }
} else { } else {
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] }; // Không chn notes: v TT C notes cùng beat (chord user 08:25)
const idxs = findCCNoteIndicesAtBeat(beat);
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: idxs };
} }
return; return;
} }
@@ -8318,19 +8480,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return; return;
} }
const candidateIdx = findCCNoteIndex(beat, y, h); // V TT C notes cùng beat (chord user 08:25) trưc đây ch 1 note
if (candidateIdx !== -1 && !painted.includes(candidateIdx)) { const candidateIdxs = findCCNoteIndicesAtBeat(beat);
const currentVal = ccMode === 'pan' ? ((notes[candidateIdx].pan || 0) / 2.0 + 0.5) : (notes[candidateIdx].velocity !== undefined ? notes[candidateIdx].velocity : 0.8); const unpainted = candidateIdxs.filter(ci => !painted.includes(ci));
if (Math.abs(currentVal - val) > 0.001) { if (unpainted.length > 0) {
const updatedNotes = notes.map((n, i) => { const updatedNotes = notes.map((n, i) => {
if (i !== candidateIdx) return n; if (!unpainted.includes(i)) return n;
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
return { ...n, velocity: val }; return { ...n, velocity: val };
}); });
setNotes(updatedNotes); setNotes(updatedNotes);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes); if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
} drag.lastPainted = [...painted, ...unpainted];
drag.lastPainted = [...painted, candidateIdx];
} }
}; };
@@ -8458,12 +8619,21 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const [selectedScale, setSelectedScale] = React.useState(null); const [selectedScale, setSelectedScale] = React.useState(null);
const selectedScaleRef = React.useRef(null); const selectedScaleRef = React.useRef(null);
selectedScaleRef.current = selectedScale; selectedScaleRef.current = selectedScale;
// Root note (0-11 C..B) transpose scale highlight + snap (spec 20:12)
const [scaleRoot, setScaleRoot] = React.useState(0);
const scaleRootRef = React.useRef(0);
scaleRootRef.current = scaleRoot;
// Modal states: Arpeggiator / Strummer / Humanize (spec 20:12)
const [arpModal, setArpModal] = React.useState(null); // { pattern, rate, octaves, gate }
const [strumModal, setStrumModal] = React.useState(null); // { ms, direction }
const [humanizeModal, setHumanizeModal] = React.useState(null); // { timingMs, velRange, durRange }
const [chordType, setChordType] = React.useState('triad'); // chord stamp (spec)
const [chordStampMode, setChordStampMode] = React.useState(false); // stamp mode toggle
const [velocityTarget, setVelocityTarget] = React.useState(80); // compress target (spec)
const snapToScaleRef = React.useRef(true); const snapToScaleRef = React.useRef(true);
snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true; snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true;
const [scaleMenuPos, setScaleMenuPos] = React.useState(null); const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
const scaleMenuOriginRef = React.useRef(null); const scaleMenuOriginRef = React.useRef(null);
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
const snapPitchToScale = (pitch, scale) => { const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch; if (!scale) return pitch;
@@ -8476,6 +8646,188 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return octave * 12 + best; return octave * 12 + best;
}; };
// MIDI Transform Engine (spec 20:12 direct integration)
const scaleWithRoot = () => {
const sc = selectedScaleRef.current;
if (!sc) return null;
return sc.map(s => (s + scaleRootRef.current) % 12);
};
const commitNotes = (updated) => {
setNotes(updated);
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updated);
};
const applyArpeggiate = (p) => {
const sc = scaleWithRoot();
const beatSec = 60.0 / (parseInt(bpm) || 120);
const rateDiv = p.rate === '1/4' ? 1 : p.rate === '1/8' ? 0.5 : p.rate === '1/16' ? 0.25 : p.rate === '1/32' ? 0.125 : 1;
const rateBeats = rateDiv * (p.triplet ? 2/3 : p.dotted ? 1.5 : 1);
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const chords = [];
targets.forEach(n => {
const k = n.start_beat.toFixed(2);
const g = chords.find(c => c.k === k);
if (g) g.notes.push(n); else chords.push({ k, notes: [n] });
});
const result = [];
const rangeNotes = []; // arpeggiated sequence (pitch per step)
chords.forEach(ch => {
const sorted = ch.notes.slice().sort((a, b) => a.pitch - b.pitch);
const notesOut = [];
for (let o = 0; o < p.octaves; o++) {
sorted.forEach(n => {
const octPitch = n.pitch + o * 12;
if (!rangeNotes.includes(octPitch)) rangeNotes.push(octPitch);
});
}
const seq = [];
rangeNotes.splice(0, rangeNotes.length);
chords.forEach(() => {});
const sortedAsc = ch.notes.slice().sort((a, b) => a.pitch - b.pitch);
const sortedDesc = sortedAsc.slice().reverse();
const pool = p.pattern === 'DOWN' ? sortedDesc
: p.pattern === 'UP-DOWN' ? [...sortedAsc, ...sortedDesc.slice(1, -1)]
: p.pattern === 'RANDOM' ? sortedAsc.slice().sort(() => Math.random() - 0.5)
: sortedAsc; // UP / CHORD
const steps = p.pattern === 'CHORD' ? 1 : pool.length * p.octaves;
const seqPitches = [];
for (let i = 0; i < steps; i++) {
if (p.pattern === 'CHORD') { pool.forEach(n => seqPitches.push(n.pitch)); break; }
seqPitches.push(pool[i % pool.length].pitch + Math.floor(i / pool.length) * 12);
}
const stepDur = p.pattern === 'CHORD' ? rateBeats * pool.length : rateBeats;
const total = seqPitches.length * stepDur;
seqPitches.forEach((pitch, i) => {
const dur = stepDur * (p.gate / 100);
const vel = ch.notes[0] ? ch.notes[0].velocity : 0.8;
notesOut.push({ id: 'note_' + Math.random().toString(36).substr(2, 9), pitch, start_beat: ch.notes[0].start_beat + i * stepDur, duration_beats: Math.max(0.05, dur), velocity: vel });
});
result.push(...notesOut);
});
const others = notes.filter(n => !targets.includes(n));
commitNotes([...others, ...result].sort((a, b) => a.start_beat - b.start_beat));
setArpModal(null);
showToast('Đã arpeggiate ' + targets.length + ' nốt.', 'success');
};
const applyStrum = (p) => {
const secPerBeat = 60.0 / (parseInt(bpm) || 120);
const strumBeats = (p.ms / 1000.0) / secPerBeat;
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const groups = [];
targets.forEach(n => {
const k = n.start_beat.toFixed(2);
const g = groups.find(g2 => g2.k === k);
if (g) g.notes.push(n); else groups.push({ k, notes: [n] });
});
let alternateFlip = false;
const result = targets.map(n => ({ ...n }));
groups.forEach(g => {
const sorted = g.notes.slice().sort((a, b) => a.pitch - b.pitch);
const asc = p.direction === 'UP' ? sorted.slice().reverse() : (p.direction === 'ALTERNATE' ? (alternateFlip ? sorted.slice().reverse() : sorted.slice()) : sorted);
alternateFlip = !alternateFlip;
const firstStart = sorted[0].start_beat;
asc.forEach((note, index) => {
result.forEach(r => {
if (r.id === note.id) {
r.start_beat = parseFloat((firstStart + index * strumBeats).toFixed(3));
r.velocity = Math.max(0.1, Math.min(1.0, parseFloat((r.velocity - index * 0.03).toFixed(2))));
}
});
});
});
commitNotes(result);
setStrumModal(null);
showToast('Đã strum ' + targets.length + ' nốt.', 'success');
};
const applyHumanizeModal = (p) => {
const spb = 60.0 / (parseInt(bpm) || 120);
const maxJitter = (p.timingMs / 1000.0) / spb;
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const result = targets.map(n => {
const tj = (Math.random() - 0.5) * 2 * maxJitter;
const vj = (Math.random() - 0.5) * 2 * (p.velRange / 127);
const dj = p.durRange ? (Math.random() - 0.5) * 2 * (p.durRange / 100) : 0;
return {
...n,
start_beat: Math.max(0, parseFloat((n.start_beat + tj).toFixed(3))),
velocity: Math.max(0.05, Math.min(1.0, parseFloat((n.velocity + vj).toFixed(2)))),
duration_beats: Math.max(0.05, parseFloat((n.duration_beats * (1 + dj)).toFixed(3)))
};
});
const others = notes.filter(n => !targets.includes(n));
commitNotes([...others, ...result]);
setHumanizeModal(null);
showToast('Đã humanize ' + targets.length + ' nốt.', 'success');
};
const applyForceToScale = () => {
const sc = scaleWithRoot();
if (!sc) { showToast('Chưa chọn scale (nhấp chuột phải chọn Scale).', 'warning'); return; }
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
const result = targets.map(n => ({ ...n, pitch: snapPitchToScale(n.pitch, sc) }));
const others = notes.filter(n => !targets.includes(n));
commitNotes([...others, ...result]);
showToast('Đã force ' + targets.length + ' nốt về scale.', 'success');
};
const CHORD_SHAPES = {
triad: [0, 4, 7],
min7: [0, 3, 7, 10],
sus4: [0, 5, 7],
add9: [0, 4, 7, 14]
};
const applyChordStamp = (beat, pitch) => {
const shape = CHORD_SHAPES[chordType] || CHORD_SHAPES.triad;
const beatSec = 60.0 / (parseInt(bpm) || 120);
const barBeats = 4;
const newNotes = shape.map(iv => ({
id: 'note_' + Math.random().toString(36).substr(2, 9),
pitch: pitch + iv,
start_beat: beat,
duration_beats: 1,
velocity: 0.8
}));
commitNotes([...notes, ...newNotes]);
showToast('Đã stamp chord (' + shape.length + ' nốt).', 'success');
};
const applyHarmonize = (interval) => {
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const dups = targets.map(n => ({ ...n, id: 'note_' + Math.random().toString(36).substr(2, 9), pitch: n.pitch + interval }));
commitNotes([...notes, ...dups]);
showToast('Đã harmonize +' + interval + ' (' + dups.length + ' nốt).', 'success');
};
const applyVelocityCompress = () => {
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const mean = targets.reduce((s, n) => s + (n.velocity !== undefined ? n.velocity : 0.8), 0) / targets.length;
const target = velocityTarget / 127;
const result = targets.map(n => {
const v = (n.velocity !== undefined ? n.velocity : 0.8);
return { ...n, velocity: Math.max(0.05, Math.min(1.0, v + (target - mean))) };
});
const others = notes.filter(n => !targets.includes(n));
commitNotes([...others, ...result]);
showToast('Đã compress velocity về ' + velocityTarget + ' (' + targets.length + ' nốt).', 'success');
};
const applyVelocityNormalize = () => {
const base = notes.filter(n => selectedNoteIds.includes(n.id));
const targets = base.length > 0 ? base : notes;
if (!targets.length) return;
const maxV = Math.max(...targets.map(n => (n.velocity !== undefined ? n.velocity : 0.8)));
if (maxV <= 0) return;
const result = targets.map(n => ({ ...n, velocity: Math.max(0.05, Math.min(1.0, (n.velocity !== undefined ? n.velocity : 0.8) / maxV)) }));
const others = notes.filter(n => !targets.includes(n));
commitNotes([...others, ...result]);
showToast('Đã normalize velocity (max → 127).', 'success');
};
const renderScaleContextMenu = () => { const renderScaleContextMenu = () => {
const closeMenu = () => setScaleMenuPos(null); const closeMenu = () => setScaleMenuPos(null);
const origin = scaleMenuOriginRef.current || scaleMenuPos; const origin = scaleMenuOriginRef.current || scaleMenuPos;
@@ -8593,16 +8945,6 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
className: "flex items-center gap-1 text-xs" className: "flex items-center gap-1 text-xs"
}, React.createElement("span", { }, React.createElement("span", {
className: "text-zinc-500 font-semibold" className: "text-zinc-500 font-semibold"
}, "Snap to Scale"), React.createElement("button", {
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s)),
className: `w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'bg-yellow-600' : 'bg-zinc-700'}`,
style: { padding: 0 }
}, React.createElement("div", {
className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'translate-x-3.5' : 'translate-x-0.5'}`
}))), React.createElement("div", {
className: "flex items-center gap-1 text-xs"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Snap:"), React.createElement("select", { }, "Snap:"), React.createElement("select", {
value: snapValue, value: snapValue,
onChange: e => { onSnapChange(e.target.value); setRenderTick(t => t + 1); }, onChange: e => { onSnapChange(e.target.value); setRenderTick(t => t + 1); },
@@ -8652,73 +8994,10 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300'; return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
}(), }(),
title: "Toggle ghost notes visibility" title: "Toggle ghost notes visibility"
}, "👻 MIDI ghost notes"), React.createElement("button", { }, "👻 MIDI ghost notes"), React.createElement("div", {
onClick: onClose,
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition ml-auto"
}, React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
}), "Đóng"), React.createElement("div", {
style: { flexBasis: "100%", height: 0 }
}), React.createElement("button", {
onClick: applyHumanize,
className: "px-2 py-1 rounded text-xs bg-amber-900/40 text-amber-300 border border-amber-700/60 hover:bg-amber-800/50 transition",
title: "Humanize: randomize velocity + timing"
}, "\uD83C\uDF9A Humanize"), React.createElement("select", {
key: "humstr", value: humanizeStrength,
onChange: function(e) { setHumanizeStrength(parseFloat(e.target.value)); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Cường độ humanize"
}, React.createElement("option", { key: "l", value: 0.05 }, "Nh\u1EB9"), React.createElement("option", { key: "m", value: 0.10 }, "V\u1EEBa"), React.createElement("option", { key: "s", value: 0.18 }, "M\u1EA1nh")), React.createElement("button", {
onClick: function() {
if (notes && notes.length) {
if (onCopyNotes) onCopyNotes(notes);
showToast('Đã copy ' + notes.length + ' nốt vào clipboard — mở tab khác + Paste.', 'success');
} else { showToast('Không có nốt để copy.', 'warning'); }
},
className: "px-2 py-1 rounded text-xs bg-cyan-900/40 text-cyan-300 border border-cyan-700/60 hover:bg-cyan-800/50 transition",
title: "Copy toàn bộ notes vào clipboard (paste sang PIANO ROLL TAB khác)"
}, "\uD83D\uDCCB Copy"), React.createElement("button", {
onClick: function() {
if (!clipboardNotes || !clipboardNotes.length) { showToast('Clipboard trống — bấm Copy trước.', 'warning'); return; }
pushToUndo(notes);
setNotes(prev => [...(prev || []), ...clipboardNotes.map(function(n) { return { ...n, id: 'note_cp_' + Date.now() + '_' + Math.floor(Math.random() * 100000) }; })]);
showToast('Đã paste ' + clipboardNotes.length + ' nốt từ clipboard.', 'success');
},
className: "px-2 py-1 rounded text-xs bg-teal-900/40 text-teal-300 border border-teal-700/60 hover:bg-teal-800/50 transition",
title: "Paste notes từ clipboard vào tab này (append cuối)"
}, "\uD83D\uDCE5 Paste"), React.createElement("div", {
key: "transpose", className: "flex items-center gap-1"
}, React.createElement("input", {
key: "in", type: "number", step: 1, min: -24, max: 24, value: transposeSemis,
onChange: function(e) { setTransposeSemis(e.target.value); },
className: "w-12 px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-center text-zinc-200",
title: "Semitone offset (vd 2 = cao hơn 1 tone)"
}), React.createElement("button", {
key: "btn", onClick: function() { applyTranspose(transposeSemis); },
className: "px-2 py-1 rounded text-xs bg-sky-900/40 text-sky-300 border border-sky-700/60 hover:bg-sky-800/50 transition",
title: "Transpose all notes by the semitone offset"
}, "Transpose")), React.createElement("div", {
key: "keyshift", className: "flex items-center gap-1"
}, React.createElement("select", {
key: "root", value: keyTargetRoot,
onChange: function(e) { setKeyTargetRoot(e.target.value); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Giọng đích (root)"
}, ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"].map(function(r) { return React.createElement("option", { key: r, value: r }, r); })), React.createElement("select", {
key: "scale", value: keyTargetScale,
onChange: function(e) { setKeyTargetScale(e.target.value); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Thể scale đích"
}, React.createElement("option", { key: "maj", value: "major" }, "major"), React.createElement("option", { key: "min", value: "minor" }, "minor")), React.createElement("button", {
key: "btn", onClick: applyTransposeToKey,
className: "px-2 py-1 rounded text-xs bg-violet-900/40 text-violet-300 border border-violet-700/60 hover:bg-violet-800/50 transition",
title: "Chuyển giọng: map degree hiện tại sang giọng đích (auto-detect key nguồn)"
}, "🎵 Chuyển giọng")), React.createElement("div", {
className: "flex items-center gap-1 ml-auto" className: "flex items-center gap-1 ml-auto"
}, React.createElement("button", { }, React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", { }, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
onClick: () => { onClick: () => {
const ppq = 480; const ppq = 480;
@@ -8769,7 +9048,132 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
showToast('Đã xuất file MIDI!', 'success'); showToast('Đã xuất file MIDI!', 'success');
}, },
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold" className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"))), }, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI")), React.createElement("div", {
style: { flexBasis: "100%", height: 0 }
}), React.createElement("button", {
onClick: () => setArpModal({ pattern: 'UP', rate: '1/16', octaves: 2, gate: 80, triplet: false, dotted: false }),
className: "px-2 py-1 rounded text-xs bg-cyan-900/40 text-cyan-300 border border-cyan-700/60 hover:bg-cyan-800/50 transition",
title: "Arpeggiate (Alt+A)"
}, "ARP"), React.createElement("button", {
onClick: () => setStrumModal({ ms: 30, direction: 'DOWN' }),
className: "px-2 py-1 rounded text-xs bg-teal-900/40 text-teal-300 border border-teal-700/60 hover:bg-teal-800/50 transition",
title: "Strum (Alt+S)"
}, "STRUM"), React.createElement("button", {
onClick: () => setHumanizeModal({ timingMs: Math.round(humanizeStrength * 100), velRange: Math.round(humanizeStrength * 127), durRange: Math.round(humanizeStrength * 50) }),
className: "px-2 py-1 rounded text-xs bg-amber-900/40 text-amber-300 border border-amber-700/60 hover:bg-amber-800/50 transition",
title: "Humanize (Alt+R)"
}, "HUMANIZE"), React.createElement("select", {
key: "humstr", value: humanizeStrength,
onChange: function(e) { var v = parseFloat(e.target.value); setHumanizeStrength(v); setHumanizeModal({ timingMs: Math.round(v * 100), velRange: Math.round(v * 127), durRange: Math.round(v * 50) }); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Mức humanize (mở modal theo mức)"
}, React.createElement("option", { key: "l", value: 0.05 }, "Nh\u1EB9"), React.createElement("option", { key: "m", value: 0.10 }, "V\u1EEBa"), React.createElement("option", { key: "s", value: 0.18 }, "M\u1EA1nh")), React.createElement("div", {
key: "transpose", className: "flex items-center gap-1"
}, React.createElement("input", {
key: "in", type: "number", step: 1, min: -24, max: 24, value: transposeSemis,
onChange: function(e) { setTransposeSemis(e.target.value); },
className: "w-12 px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-center text-zinc-200",
title: "Semitone offset (vd 2 = cao hơn 1 tone)"
}), React.createElement("button", {
key: "btn", onClick: function() { applyTranspose(transposeSemis); },
className: "px-2 py-1 rounded text-xs bg-sky-900/40 text-sky-300 border border-sky-700/60 hover:bg-sky-800/50 transition",
title: "Transpose all notes by the semitone offset"
}, "Transpose")), React.createElement("div", {
key: "keyshift", className: "flex items-center gap-1"
}, React.createElement("select", {
key: "root", value: keyTargetRoot,
onChange: function(e) { setKeyTargetRoot(e.target.value); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Giọng đích (root)"
}, ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"].map(function(r) { return React.createElement("option", { key: r, value: r }, r); })), React.createElement("select", {
key: "scale", value: keyTargetScale,
onChange: function(e) { setKeyTargetScale(e.target.value); },
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
title: "Thể scale đích"
}, React.createElement("option", { key: "maj", value: "major" }, "major"), React.createElement("option", { key: "min", value: "minor" }, "minor")), React.createElement("button", {
key: "btn", onClick: applyTransposeToKey,
className: "px-2 py-1 rounded text-xs bg-violet-900/40 text-violet-300 border border-violet-700/60 hover:bg-violet-800/50 transition",
title: "Chuyển giọng: map degree hiện tại sang giọng đích (auto-detect key nguồn)"
}, "🎵 Chuyển giọng")), /* ── CÙNG HÀNG (sau Chuyển giọng — user 08:40: gộp 1 hàng, bỏ spacer) ── */ React.createElement("div", {
className: "flex items-center gap-1 text-xs"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Snap to Scale"), React.createElement("button", {
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s)),
className: `w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'bg-yellow-600' : 'bg-zinc-700'}`,
style: { padding: 0 }
}, React.createElement("div", {
className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'translate-x-3.5' : 'translate-x-0.5'}`
}))), React.createElement("div", {
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Scale:"), React.createElement("select", {
value: JSON.stringify(selectedScale || null),
onChange: e => {
const v = e.target.value;
setSelectedScale(v === 'null' ? null : JSON.parse(v));
setRenderTick(t => t + 1);
},
className: "bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[110px]"
}, React.createElement("option", { value: "null" }, "None"), (function() {
const opts = [];
const pushKey = (label, val) => opts.push(React.createElement("option", { key: label, value: JSON.stringify(val) }, label));
Object.keys(SCALES || {}).forEach(k => {
const v = SCALES[k];
if (v === null) return;
if (Array.isArray(v)) pushKey(k, v);
else Object.keys(v).forEach(sk => pushKey(sk, v[sk]));
});
return opts;
}())), React.createElement("select", {
value: scaleRoot,
onChange: e => { setScaleRoot(parseInt(e.target.value) || 0); setRenderTick(t => t + 1); },
className: "bg-zinc-800 border border-zinc-700 text-sm text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500"
}, ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'].map((r, i) => React.createElement("option", { key: r, value: i }, r))), React.createElement("button", {
onClick: () => applyForceToScale(),
title: "Force selected notes to scale",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-amber-400 border border-zinc-700 hover:border-amber-600"
}, "Force")), React.createElement("div", {
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold ml-1"
}, "Chord:"), React.createElement("select", {
value: chordType,
onChange: e => setChordType(e.target.value),
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-sm outline-none"
}, React.createElement("option", { value: "triad" }, "Triad"), React.createElement("option", { value: "min7" }, "Min7"), React.createElement("option", { value: "sus4" }, "Sus4"), React.createElement("option", { value: "add9" }, "Add9")), React.createElement("button", {
onClick: () => setChordStampMode(m => !m),
title: "Chord stamp mode — click canvas to stamp chord (Shift+C)",
className: `px-1.5 py-0.5 rounded text-sm border ${chordStampMode ? 'bg-amber-800/60 text-amber-300 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-600'}`
}, "Stamp"), React.createElement("button", {
onClick: () => applyHarmonize(3),
title: "Harmonize +3rd",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+3"), React.createElement("button", {
onClick: () => applyHarmonize(5),
title: "Harmonize +5th",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+5"), React.createElement("button", {
onClick: () => applyHarmonize(7),
title: "Harmonize +7th",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-emerald-600"
}, "+7")), React.createElement("div", {
className: "flex items-center gap-1 text-sm"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
}, "Vel:"), React.createElement("button", {
onClick: () => applyVelocityCompress(),
title: "Compress velocity toward target",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"
}, "Comp"), React.createElement("input", {
type: "number", value: velocityTarget, onChange: e => setVelocityTarget(parseInt(e.target.value) || 80),
className: "w-14 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-sm text-center"
}), React.createElement("button", {
onClick: () => applyVelocityNormalize(),
title: "Normalize (max → 127)",
className: "px-1.5 py-0.5 rounded text-sm bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"
}, "Norm"))),
/* 2. BAR RULER */ /* 2. BAR RULER */
React.createElement("div", { React.createElement("div", {
@@ -8900,7 +9304,16 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
/* 3. MAIN PIANO ROLL GRID (KEYBOARD + TRACK COL + CANVAS) */ /* 3. MAIN PIANO ROLL GRID (KEYBOARD + TRACK COL + CANVAS) */
React.createElement("div", { React.createElement("div", {
className: "flex-1 flex overflow-hidden min-h-0 relative" className: "flex-1 flex overflow-hidden min-h-0 relative",
onMouseEnter: function() {
prMouseInRef.current = true;
// Status bar gi ý đng (user 09:40): trong piano roll base hint;
// gi Shift select/unselect; gi Ctrl fast copy
if (window.__setPrHint) {
window.__setPrHint(prKeyStateRef.current.ctrl ? "Ctrl+drag: fast copy notes | Ctrl+click empty: marquee chọn notes" : (prKeyStateRef.current.shift ? "Shift+Click: select/unselect notes | Shift+Click note: thêm/bớt vào nhóm chọn" : "Scroll: Up/Down | Drag: Draw notes"));
}
},
onMouseLeave: function() { prMouseInRef.current = false; if (window.__setPrHint) window.__setPrHint(null); }
}, /* Track column */ React.createElement("div", { }, /* Track column */ React.createElement("div", {
className: "w-[120px] bg-[#16161a] border-r border-zinc-800 shrink-0 flex flex-col z-10", className: "w-[120px] bg-[#16161a] border-r border-zinc-800 shrink-0 flex flex-col z-10",
style: { height: KeybedPixelHeight + 'px' } style: { height: KeybedPixelHeight + 'px' }
@@ -8913,19 +9326,30 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
var track = (activeTracks || []).find(function(t) { return t.id === m._trackId; }); var track = (activeTracks || []).find(function(t) { return t.id === m._trackId; });
var isActive = m._trackId === st.trackId && m.id === st.target_id; var isActive = m._trackId === st.trackId && m.id === st.target_id;
var isPlayOn = activePlayTrackIds && activePlayTrackIds.indexOf(m._trackId) !== -1; var isPlayOn = activePlayTrackIds && activePlayTrackIds.indexOf(m._trackId) !== -1;
els.push(React.createElement("button", { els.push(React.createElement("div", {
key: m._trackId, key: m._trackId,
className: "flex items-center gap-0.5 mx-1 my-[2px]"
}, React.createElement("button", {
onClick: function() { onClick: function() {
// Click nút tên track ACTIVE ghost notes ca track đó thành MAIN
// notes đ chnh sa (user 09:40)
handleSwitchMidiItem(m.id);
},
className: "flex items-center justify-center flex-1 h-[26px] min-w-0 border border-zinc-600 rounded-md cursor-pointer outline-none " + (isActive ? 'bg-yellow-600 text-black font-bold' : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')
}, React.createElement("span", {
className: "text-[13px] leading-none font-sans truncate px-1",
title: track ? track.name : m._trackName
}, track ? track.name : m._trackName)), React.createElement("button", {
onClick: function(e) {
e.stopPropagation();
var prevList = activePlayTrackIds || []; var prevList = activePlayTrackIds || [];
var nextList = prevList.indexOf(m._trackId) !== -1 ? prevList.filter(function(id) { return id !== m._trackId; }) : prevList.concat([m._trackId]); var nextList = prevList.indexOf(m._trackId) !== -1 ? prevList.filter(function(id) { return id !== m._trackId; }) : prevList.concat([m._trackId]);
setActivePlayTrackIds(nextList); setActivePlayTrackIds(nextList);
if (onRealtimePlay) onRealtimePlay(nextList); if (onRealtimePlay) onRealtimePlay(nextList);
}, },
className: "flex items-center justify-center h-[20px] border border-zinc-600 rounded-md cursor-pointer outline-none mx-1 my-[2px] " + (isActive ? 'bg-yellow-600 text-black font-bold' : (isPlayOn ? 'bg-red-700 text-white font-semibold' : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')) title: isPlayOn ? "Unmute — ghost track play cùng main notes" : "Mute (mặc định) — click để play ghost cùng main",
}, React.createElement("span", { className: "w-6 h-[26px] shrink-0 border border-zinc-600 rounded-md cursor-pointer text-[11px] font-bold outline-none flex items-center justify-center " + (isPlayOn ? 'bg-green-700 text-white' : 'bg-zinc-800 text-zinc-500 hover:text-zinc-300')
className: "text-[14px] font-sans truncate px-1", }, isPlayOn ? "\u266A" : "M")));
title: track ? track.name : m._trackName
}, track ? track.name : m._trackName)));
}); });
return els; return els;
}() : null)), React.createElement("div", { }() : null)), React.createElement("div", {
@@ -9008,6 +9432,27 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
})))), })))),
/* 5. OVERLAY / CONTEXT MENU */ /* 5. OVERLAY / CONTEXT MENU */
(arpModal && React.createElement("div", {
className: "absolute inset-0 z-40 flex items-center justify-center bg-black/60",
onMouseDown: e => e.stopPropagation(),
onClick: e => e.stopPropagation()
}, React.createElement("div", {
className: "bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"
}, React.createElement("div", { className: "text-sm font-bold text-cyan-300 mb-3" }, "Arpeggiate"), React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs mb-3" }, React.createElement("label", { className: "text-zinc-500" }, "Pattern"), React.createElement("select", { value: arpModal.pattern, onChange: e => setArpModal({ ...arpModal, pattern: e.target.value }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5" }, ['UP', 'DOWN', 'UP-DOWN', 'RANDOM', 'CHORD'].map(p => React.createElement("option", { key: p, value: p }, p))), React.createElement("label", { className: "text-zinc-500" }, "Rate"), React.createElement("select", { value: arpModal.rate, onChange: e => setArpModal({ ...arpModal, rate: e.target.value }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5" }, ['1/4', '1/8', '1/16', '1/32'].map(r => React.createElement("option", { key: r, value: r }, r))), React.createElement("label", { className: "text-zinc-500" }, "Octaves"), React.createElement("input", { type: "number", min: 1, max: 4, value: arpModal.octaves, onChange: e => setArpModal({ ...arpModal, octaves: parseInt(e.target.value) || 1 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14" }), React.createElement("label", { className: "text-zinc-500" }, "Gate %"), React.createElement("input", { type: "number", min: 10, max: 200, value: arpModal.gate, onChange: e => setArpModal({ ...arpModal, gate: parseInt(e.target.value) || 80 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14" })), React.createElement("div", { className: "flex gap-1 mb-3" }, React.createElement("label", { className: "flex items-center gap-1 text-[10px] text-zinc-400" }, React.createElement("input", { type: "checkbox", checked: !!arpModal.triplet, onChange: e => setArpModal({ ...arpModal, triplet: e.target.checked }) }), "Triplet"), React.createElement("label", { className: "flex items-center gap-1 text-[10px] text-zinc-400" }, React.createElement("input", { type: "checkbox", checked: !!arpModal.dotted, onChange: e => setArpModal({ ...arpModal, dotted: e.target.checked }) }), "Dotted")), React.createElement("div", { className: "flex gap-2" }, React.createElement("button", { onClick: () => applyArpeggiate(arpModal), className: "flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white" }, "Apply"), React.createElement("button", { onClick: () => setArpModal(null), className: "px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300" }, "Cancel"))))),
(strumModal && React.createElement("div", {
className: "absolute inset-0 z-40 flex items-center justify-center bg-black/60",
onMouseDown: e => e.stopPropagation(),
onClick: e => e.stopPropagation()
}, React.createElement("div", {
className: "bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"
}, React.createElement("div", { className: "text-sm font-bold text-cyan-300 mb-3" }, "Strum"), React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs mb-3" }, React.createElement("label", { className: "text-zinc-500" }, "Strum ms"), React.createElement("input", { type: "number", min: 0, max: 120, value: strumModal.ms, onChange: e => setStrumModal({ ...strumModal, ms: parseInt(e.target.value) || 0 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16" }), React.createElement("label", { className: "text-zinc-500" }, "Direction"), React.createElement("select", { value: strumModal.direction, onChange: e => setStrumModal({ ...strumModal, direction: e.target.value }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5" }, ['DOWN', 'UP', 'ALTERNATE'].map(d => React.createElement("option", { key: d, value: d }, d)))), React.createElement("div", { className: "flex gap-2" }, React.createElement("button", { onClick: () => applyStrum(strumModal), className: "flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white" }, "Apply"), React.createElement("button", { onClick: () => setStrumModal(null), className: "px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300" }, "Cancel"))))),
(humanizeModal && React.createElement("div", {
className: "absolute inset-0 z-40 flex items-center justify-center bg-black/60",
onMouseDown: e => e.stopPropagation(),
onClick: e => e.stopPropagation()
}, React.createElement("div", {
className: "bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"
}, React.createElement("div", { className: "text-sm font-bold text-cyan-300 mb-3" }, "Humanize"), React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs mb-3" }, React.createElement("label", { className: "text-zinc-500" }, "Timing ±ms"), React.createElement("input", { type: "number", min: 0, max: 30, value: humanizeModal.timingMs, onChange: e => setHumanizeModal({ ...humanizeModal, timingMs: parseInt(e.target.value) || 0 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16" }), React.createElement("label", { className: "text-zinc-500" }, "Vel ±(0-127)"), React.createElement("input", { type: "number", min: 0, max: 20, value: humanizeModal.velRange, onChange: e => setHumanizeModal({ ...humanizeModal, velRange: parseInt(e.target.value) || 0 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16" }), React.createElement("label", { className: "text-zinc-500" }, "Dur ±%"), React.createElement("input", { type: "number", min: 0, max: 50, value: humanizeModal.durRange, onChange: e => setHumanizeModal({ ...humanizeModal, durRange: parseInt(e.target.value) || 0 }), className: "bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16" })), React.createElement("div", { className: "flex gap-2" }, React.createElement("button", { onClick: () => applyHumanizeModal(humanizeModal), className: "flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white" }, "Apply"), React.createElement("button", { onClick: () => setHumanizeModal(null), className: "px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300" }, "Cancel"))))),
scaleMenuPos && renderScaleContextMenu() scaleMenuPos && renderScaleContextMenu()
); );
}; };
@@ -14439,6 +14884,13 @@ const App = () => {
// Context Menu & Clipboard // Context Menu & Clipboard
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
// Gi ý đng PIANO ROLL trên status bar (user 09:40) piano roll set qua
// window.__setPrHint (mouse in/out + Shift/Ctrl state)
const [prHint, setPrHint] = useState(null);
React.useEffect(() => {
window.__setPrHint = (h) => setPrHint(h || null);
return () => { delete window.__setPrHint; };
}, []);
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
// Undo/Redo Engine (LOOP_EDITOR.md §4 + Global Extension) // Undo/Redo Engine (LOOP_EDITOR.md §4 + Global Extension)
@@ -16237,7 +16689,7 @@ const App = () => {
handleSubTabLoop(curTabId, 4); handleSubTabLoop(curTabId, 4);
return; return;
} }
if (e.key === 'v' || e.key === 'V') { if (!ctrl && !e.metaKey && !e.altKey && (e.key === 'v' || e.key === 'V')) {
e.preventDefault(); e.preventDefault();
const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); const val = prompt("Nhập Gain điều chỉnh (dB):", "0");
if (val) handleSubTabGain(curTabId, parseFloat(val) || 0); if (val) handleSubTabGain(curTabId, parseFloat(val) || 0);
@@ -28396,14 +28848,9 @@ STRICT CONSTRAINTS:
}, /*#__PURE__*/React.createElement("i", { }, /*#__PURE__*/React.createElement("i", {
"data-lucide": "info", "data-lucide": "info",
className: "w-3 h-3 text-zinc-600" className: "w-3 h-3 text-zinc-600"
})), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", { })), prHint ? /*#__PURE__*/React.createElement("span", {
className: "flex items-center gap-1" className: "text-cyan-400"
}, /*#__PURE__*/React.createElement("span", { }, prHint) : " Scroll: Zoom"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "keyboard",
className: "w-3 h-3 text-zinc-600"
})), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", {
className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto", className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 overflow-y-auto",
style: { style: {
left: Math.min(contextMenu.x, window.innerWidth - 260), left: Math.min(contextMenu.x, window.innerWidth - 260),
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/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=202608070820" defer></script> <script src="/static/js/app.precompiled.js?v=202608070950" 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 {
+142
View File
@@ -2783,3 +2783,145 @@
- **FIX (app.jsx canvas MAIN — drawSection subMidi notes):** skip note `noteStartSec >= (item.duration || 0) - 0.01` — note ngoài duration (phần đã trim) KHÔNG vẽ. - **FIX (app.jsx canvas MAIN — drawSection subMidi notes):** skip note `noteStartSec >= (item.duration || 0) - 0.01` — note ngoài duration (phần đã trim) KHÔNG vẽ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070820), `wiki.md`. Rebuild precompiled (build PASS). - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070820), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → SECTION-TAB kéo ngắn midi item (4 bars) → quay MAIN → section item canvas hiển thị ĐÚNG 4 bars (phần 5-8 trống); scheduling đã skip note ngoài duration (19331). - **Ghi chú/Test:** hard refresh → SECTION-TAB kéo ngắn midi item (4 bars) → quay MAIN → section item canvas hiển thị ĐÚNG 4 bars (phần 5-8 trống); scheduling đã skip note ngoài duration (19331).
### [2026-08-07 08:25] Task: PIANO ROLL — ctrl+draw velocity chord (nhiều notes cùng vị trí) chỉ vẽ được 1 note
- **Báo cáo user:** velocity lane — notes CÙNG vị trí (chord) khó draw — draw qua vị trí phải vẽ lại TẤT CẢ velocity (nếu notes không được chọn).
- **Nguyên nhân:** findCCNoteIndex trả 1 note (gần stemTop nhất) → ctrl+draw chỉ đổi 1 note — chord notes còn lại giữ nguyên.
- **FIX (app.jsx PianoRollTabEditor):** helper `findCCNoteIndicesAtBeat(b)` — trả TẤT CẢ indices notes có start_beat == snapped — handleCCMouseDown (nhánh KHÔNG chọn): lastPainted = all-at-beat; handleCCMouseMove (non-selected): vẽ TẤT CẢ unpainted notes tại beat (velocity/pan). Selected mode giữ nguyên (chỉ vẽ notes chọn).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070825), `wiki.md`. Rebuild precompiled (build PASS — findCCNoteIndicesAtBeat ×3).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — chord notes cùng beat — ctrl+draw qua cột velocity → TẤT CẢ notes cùng beat đổi velocity (không chọn); có notes chọn → chỉ notes chọn đổi.
### [2026-08-07 08:30] Task: Tích hợp MIDI Toolkit vào Piano Roll Editor (spec 20:12)
- **Spec:** Scale Highlight/Lock, Arpeggiator, Strummer, Humanize, Chord Generator, Velocity Shaper — tích hợp trực tiếp Piano Roll (không qua FX Rack).
- **ĐÃ CÓ SẴN:** snapPitchToScale + selectedScale (menu chuột phải), snapToScale toggle, humanize basic (7144).
- **THÊM MỚI (app.jsx PianoRollTabEditor):**
1. **Scale:** state `scaleRoot` (C..B); toolbar [Scale ▾][Root ▾][Force] — flatten SCALES; `scaleWithRoot()` (transpose); **highlight grid** (rows in-scale sáng vàng / out-of-scale dim — canvas 7394); `applyForceToScale` (notes chọn/all → snap pitch).
2. **Arpeggiator:** modal (pattern UP/DOWN/UP-DOWN/RANDOM/CHORD, rate 1/4-1/32 + triplet/dotted, octaves 1-4, gate 10-200%) — `applyArpeggiate` (chord notes → sequential).
3. **Strummer:** modal (ms 0-120, direction DOWN/UP/ALTERNATE) — `applyStrum` (group same start_beat, sort pitch, Δt + velocity taper 0.03/note).
4. **Humanize:** modal (timing ±ms, vel ±, dur ±%) — `applyHumanizeModal` (jitter start_beat/velocity/duration) — nút Humanize cũ + Alt+R mở modal.
5. **Chord:** [Chord ▾] Triad/Min7/Sus4/Add9 + [Stamp] mode (click canvas → `applyChordStamp` full chord) + Harmonize +3/+5/+7 (`applyHarmonize`).
6. **Velocity:** [Vel: Comp][target][Norm] — `applyVelocityCompress` (shift về target mean), `applyVelocityNormalize` (max → 127).
7. **Shortcuts:** Alt+A (Arp), Alt+S (Strum), Alt+R (Humanize), Shift+C (Stamp), Shift+S (Lock scale toggle).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070830), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → chọn scale (toolbar) → grid highlight; chọn notes → Arp/Strum/Humanize modal → Apply; [Stamp] + click canvas → chord; chọn notes → Comp/Norm velocity; Force đưa notes về scale.
### [2026-08-07 08:35] Task: Toolbar Piano Roll — gom 2 hàng + merge tool trùng
- **Yêu cầu user:** toolbar có công cụ TRÙNG — move + merge vào HÀNG DƯỚI.
- **Thực hiện (app.jsx):**
(1) Move khối [Snap to Scale toggle + Scale: ▾/Root ▾/Force + Tools: Arp/Strum/Humanize + Chord: ▾/Stamp/+3/+5/+7 + Vel: Comp/target/Norm] từ giữa hàng 1 → CUỐI toolbar (hàng 2 — sau nút "🎵 Chuyển giọng") với spacer `flex-basis:100%` ép xuống hàng mới (container vốn flex-wrap 2 hàng).
(2) MERGE trùng: nút "🎚 Humanize + strength" cũ (hàng 1) — XÓA (thay bằng Tools:Humanize modal hàng 2 — modal đã có tham số timing/vel/dur).
(3) Hàng 1 giờ gọn: track select, Snap, ARM, Input, Instrument, AI, CC mode, Vel/Sus/Mod/Bend/Pan, Session/Isolated, Copy/Paste/Undo/Redo/Chuyển giọng, Lưu/Export.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070835), `wiki.md`. Rebuild precompiled (build PASS — humstr đã xóa).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 2 hàng (hàng 1: cơ bản; hàng 2: Scale/Tools/Chord/Vel) — không còn trùng Humanize.
### [2026-08-07 08:40] Task: Toolbar PIANO ROLL — bỏ Copy/Paste, thêm ARP/STRUM/HUMANIZE, gộp 1 hàng
- **Yêu cầu user:** bỏ 2 nút COPY/PASTE (dùng Ctrl-C/X/V + context menu thay); đặt 3 nút ARP/STRUM/HUMANIZE + [dropdown mức humanize] vào chỗ vừa xóa; di chuyển các thành phần còn lại lên cùng hàng với dãy nút này, phía sau nút Chuyển giọng.
- **Thực hiện (app.jsx PianoRollTabEditor toolbar):**
(1) Xóa nút 📋 Copy + 📥 Paste (8882-8900 cũ) → thay bằng ARP/STRUM/HUMANIZE + select mức (Nhẹ 0.05/Vừa 0.10/Mạnh 0.18 — chọn mức → setHumanizeStrength + mở modal theo mức: timingMs=round(v*100), velRange=round(v*127), durRange=round(v*50)).
(2) Xóa spacer flexBasis:100% (ép hàng 2) + xóa khối "Tools: Arp/Strum/Humanize" trùng (hàng 2) → toolbar GỘP 1 HÀNG: ... Chuyển giọng → [Snap to Scale][Scale ▾][Root ▾][Force][Chord ▾][Stamp][+3/+5/+7][Vel: Comp/Norm] → Lưu/Export.
(3) Ctrl+C/X/V trong PIANO ROLL TAB (keydown 7106): C = copy notes (onCopyNotes + toast), X = copy + xóa hết notes, V = paste clipboardNotes (append — như nút cũ) — deps thêm clipboardNotes (handler re-create khi clipboard đổi).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070840), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → toolbar 1 hàng (không còn Copy/Paste); ARP/STRUM/HUMANIZE + mức humanize hoạt động (mở modal); Ctrl+C → Ctrl+V paste notes; Ctrl+X cắt notes; Alt+A/S/R mở tool.
### [2026-08-07 08:45] Task: (1) Lưu + Export MIDI lên trước vị trí nút Đóng, bỏ Đóng (2) Velocity không cập nhật realtime khi đổi độ rộng/bật tắt thanh velocity
- **Yêu cầu user:** (1) mang 2 nút Lưu + Export MIDI lên trước nút Đóng + remove nút Đóng; (2) velocity note không cập nhật realtime khi thay đổi ccHeight (độ rộng) hoặc showCC (bật/tắt thanh velocity).
- **FIX (app.jsx PianoRollTabEditor):**
(1) Xóa nút "Đóng" (onClose) — chèn div ml-auto [Lưu + Export MIDI] (di chuyển từ cuối toolbar — xóa bản cũ); sửa cân bằng ngoặc (bản cũ `"Export MIDI")))` đóng cả toolbar div — thêm `)` tại `"Norm")))`).
(2) Effect vẽ CC canvas — thêm `ccHeight, showCC` vào deps (`[notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset, ccHeight, showCC]`) — đổi độ rộng/bật tắt → vẽ lại realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070845), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar: không còn nút Đóng; Lưu/Export ở cuối (trước hàng tool Scale/Chord/Vel); kéo đổi độ rộng thanh velocity / bấm Vel toggle → velocity bars cập nhật ngay.
### [2026-08-07 08:50] Task: FIX TDZ — ccHeight/showCC khai báo SAU effect vẽ CC
- **Lỗi user:** `Uncaught ReferenceError: Cannot access 'ccHeight' before initialization`.
- **Nguyên nhân:** 08:45 thêm ccHeight/showCC vào deps useLayoutEffect vẽ CC (7620) — NHƯNG 2 state khai báo ở 8542 (SAU effect) → deps array tham chiếu biến trước khai báo → TDZ error khi render.
- **FIX (app.jsx):** di chuyển `const [showCC, setShowCC]` + `const [ccHeight, setCcHeight]` LÊN TRƯỚC useLayoutEffect vẽ CC (7619) — xóa bản trùng cũ (8542).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070850), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → mở PIANO ROLL → KHÔNG còn lỗi TDZ; đổi độ rộng thanh velocity / toggle Vel → vẽ lại realtime.
### [2026-08-07 08:55] Task: Tăng size tool từ "Snap to Scale" đến cuối hàng
- **Yêu cầu user:** tăng size các tool từ Snap to Scale đến cuối hàng (Snap to Scale toggle, Scale ▾, Root ▾, Force, Chord ▾, Stamp, +3/+5/+7, Vel: Comp/Norm).
- **FIX (app.jsx):** trong khối (Snap to Scale → Norm) — `text-[10px]``text-xs` (12px) — 9 chỗ (Force/Stamp/+3/+5/+7/Comp/Norm buttons + Chord select...).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070855), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar → các tool cuối hàng to hơn (text-xs).
### [2026-08-07 09:00] Task: Đồng bộ font hàng tool (Snap to Scale → Norm) lên text-sm
- **Yêu cầu user:** tăng kích thước font chữ trong hàng đó cho ĐỒNG BỘ.
- **FIX (app.jsx):** khối Snap to Scale → Norm — `text-xs``text-sm` (12 chỗ: 5 container div + buttons + Chord select + nhãn) + thêm `text-sm` vào 2 select Scale/Root (mặc định browser) — toàn hàng đồng bộ 14px.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070900), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL toolbar hàng tool — font đồng bộ text-sm (14px).
### [2026-08-07 09:05] Task: Tăng chiều rộng input target velocity (Vel Comp)
- **Yêu cầu user:** tăng chiều rộng scrollbox/input của Vel Comp để chứa đủ số velocity (127).
- **FIX (app.jsx):** input `velocityTarget` (bên cạnh nút Comp) — `w-9``w-14` (đủ 3 chữ số 0-127).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070905), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → input target (cạnh Comp) rộng hơn, hiện đủ "127".
### [2026-08-07 09:10] Task: Ctrl+C/X trong PIANO ROLL — CHỈ copy/cắt notes được chọn
- **Yêu cầu user:** Ctrl-X/Ctrl-C trong PIANO ROLL TAB chỉ cắt/copy các NOTES ĐƯỢC CHỌN.
- **FIX (app.jsx keydown 7106):** Ctrl+C — copy `notes.filter(selectedNoteIds)` (toast số nốt chọn; không chọn → toast "Chọn notes trước khi copy"); Ctrl+X — copy + xóa notes chọn (filter bỏ selectedNoteIds); Ctrl+V giữ nguyên (paste clipboard).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070910), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — chọn 1 số notes → Ctrl+C → Ctrl+V paste đúng notes chọn; Ctrl+X chỉ cắt notes chọn; không chọn → toast nhắc.
### [2026-08-07 09:15] Task: Ctrl+drag marquee chọn notes → Ctrl+X báo "Chọn notes trước khi cắt"
- **Báo cáo user:** Ctrl+drag marquee chọn notes → Ctrl+X → lỗi "Chọn notes trước khi cắt".
- **Nguyên nhân:** marquee mousemove (8018-8033) ĐÃ chọn notes live (setSelectedNoteIds) — NHƯNG keydown handler (Ctrl+C/X — 7106) deps thiếu `selectedNoteIds` → closure STALE (selectedNoteIds rỗng từ render trước) → filter = [] → toast lỗi.
- **FIX (app.jsx):** (a) thêm `selectedNoteIds` vào deps effect keydown 7106; (b) bổ sung — handleGridMouseUp khi kết thúc marquee → chọn lại notes trong vùng (đảm bảo dù không qua mousemove lần cuối).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070915), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+drag marquee (notes highlight) → Ctrl+X → cắt đúng notes trong vùng; Ctrl+C → copy.
### [2026-08-07 09:20] Task: Ctrl+V trong PIANO ROLL hiện prompt "Nhập Gain điều chỉnh (dB)"
- **Báo cáo user:** nhấn Ctrl-V trong PIANO ROLL TAB → browser prompt "Nhập Gain điều chỉnh (dB):" — cần remove trong piano roll tab.
- **Nguyên nhân:** sub-tab keydown handler (16590) bắt `e.key === 'v'` KHÔNG kiểm tra Ctrl → Ctrl+V cũng trúng → prompt gain (fade gain shortcut 'v' đơn).
- **FIX (app.jsx):** thêm `!ctrl && !e.metaKey && !e.altKey` — chỉ 'v' đơn (không modifier) mới mở prompt; Ctrl+V → chạy paste notes piano roll (handler 7106).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070920), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+V → paste notes (KHÔNG prompt); nhấn 'v' đơn → prompt gain như cũ (sub-tab audio).
### [2026-08-07 09:25] Task: Ctrl+V dán notes tại vị trí con trỏ playhead
- **Yêu cầu user:** Ctrl-V dán các notes đã cut/copy bắt đầu ở vị trí con trỏ playhead đang đứng.
- **FIX (app.jsx keydown 7106 — Ctrl+V):** `pasteBeat = st.currentTime / (60/bpm)` (playhead → beat) — `offset = pasteBeat - minStart(clipboard)` — dán với `start_beat = n.start_beat + offset` (note đầu tiên nằm đúng playhead; clamp ≥ 0).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070925), `wiki.md`. Rebuild precompiled (build PASS — pasteBeat ×2).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — di chuyển playhead → Ctrl+V → notes dán bắt đầu đúng vị trí playhead.
### [2026-08-07 09:30] Task: PIANO ROLL — Shift+click toggle chọn; Ctrl+click+drag COPY nhanh; drag move pitch
- **Yêu cầu user:** (1) Ctrl+Click → Shift+Click để thêm/bớt note vào nhóm chọn; (2) Ctrl+Click giữ + drag → copy nhanh nhóm notes/note đến vị trí mới (cùng pitch — drag đổi vị trí/pitch); (3) drag note/nhóm → vị trí + pitch khác.
- **Trạng thái:** (3) ĐÃ CÓ (7895-7922 — mode 'move' — deltaBeat + deltaPitch).
- **FIX (app.jsx PianoRollTabEditor handleGridMouseDown):**
(1) toggle selection: `e.ctrlKey``e.shiftKey` (Shift+click thêm/bớt note).
(2) Ctrl+click TRÊN NOTE → clone notes (nhóm chọn nếu note trong nhóm, ngược lại note đơn) + setDraggedNote mode 'move' (selectedNotesOffset + clickedOriginalStartBeat) → kéo clones đến vị trí/pitch mới (gốc giữ) — mousemove move có sẵn.
(3) Ctrl+click empty → marquee (giữ); Ctrl+Shift+click → split (giữ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070930), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Shift+click thêm/bớt note chọn; Ctrl+giữ+click note → kéo → copy nhóm đến vị trí mới; drag note (không modifier) → move vị trí + pitch.
### [2026-08-07 09:35] Task: FIX Ctrl+click+drag copy — notes không bám vị trí con trỏ
- **Báo cáo user:** ctrl-click-drag — notes không được copy ngay tại vị trí con trỏ chuột.
- **Nguyên nhân:** copy-drag set `startOffsetBeat: beat` (vị trí chuột) — move mousemove tính `deltaBeat = snap(beatMouse - startOffsetBeat) - refOrigStart` → delta bị lệch (Δ=0 → -noteStart → clones nhảy về beat 0 / không bám chuột). Move thường dùng `startOffsetBeat = beat - clickedNote.start_beat` (offset trong note).
- **FIX (app.jsx):** copy-drag → `startOffsetBeat: beat - clickedNote.start_beat` (giống move thường) → kéo → delta = snap(Δ) → clones bám chuột (vị trí + pitch).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070935), `wiki.md`. Rebuild precompiled (build PASS — 2 chỗ đồng bộ).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — Ctrl+click note → kéo → các bản copy theo đúng vị trí con trỏ (beat + pitch).
### [2026-08-07 09:40] Task: PIANO ROLL — (1) nút track column: active ghost → main + MUTE (2) follow playhead (3) status hint động
- **Yêu cầu user:**
(1) Cột trái (nút tên track): click → active ghost notes của track đó thành MAIN notes để chỉnh sửa; cuối nút thêm nút MUTE (mặc định MUTE — unmute → play ghost cùng main notes — chức năng tương tự nút toggle cũ).
(2) Khi play → playhead di chuyển GIỮA piano roll, notes trôi sang trái; stop → playhead về ĐẦU (luôn hiển thị trong view).
(3) Status bar gợi ý: mouse trong piano roll → "Scroll: Up/Down | Drag: Draw notes"; giữ Shift → "Shift+Click: select/unselect..."; giữ Ctrl → "Ctrl+drag: fast copy notes...".
- **FIX (app.jsx):**
(1) Track column (9270): nút tên track → `handleSwitchMidiItem(m.id)` (mở item — chỉnh sửa main notes); thêm nút MUTE (w-6 — mặc định 'M' zinc, unmute '♪' green — toggle activePlayTrackIds + onRealtimePlay).
(2) Effect follow: `[isPlaying, st.currentTime]` — play → `scrollLeft = phBeat*ppb - clientWidth/2` (playhead giữa, notes trôi trái); stop → `scrollLeft = 0` (về đầu).
(3) App state `prHint` + `window.__setPrHint`; piano roll: `prKeyStateRef`/`prMouseInRef` + keydown/keyup + container onMouseEnter/Leave → set hint theo Ctrl/Shift; status bar hiển thị `prHint` (cyan) thay "Scroll: Zoom".
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070940), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — click tên track khác → mở item đó (sửa được); nút M — click → ♪ xanh (play cùng main); play → playhead giữa + trôi trái; stop → về đầu; hover trong piano roll → status bar đổi gợi ý theo Shift/Ctrl.
### [2026-08-07 09:45] Task: (1) Cân bằng tên track cột trái (2) Xóa nhãn "Ctrl+Scroll: Playhead" cố định
- **Yêu cầu user:** (1) sửa chiều cao nút tên track cột trái — tên nút cân bằng giữa nút; (2) xóa nhãn cố định "Ctrl+Scroll: Playhead" cuối status bar — để gợi ý adaptive hiển thị đủ nội dung.
- **FIX (app.jsx):**
(1) Nút tên track: `h-[20px]``h-[26px]` + `flex items-center justify-center` + text `text-[13px] leading-none`; nút MUTE đồng bộ `h-[26px]` + flex center.
(2) Status bar: xóa span "|" + span "Ctrl+Scroll: Playhead" (icon keyboard) — còn `prHint` (cyan) hoặc "Scroll: Zoom" — gợi ý adaptive có đủ chỗ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070945), `wiki.md`. Rebuild precompiled (build PASS — Ctrl+Scroll: Playhead = 0).
- **Ghi chú/Test:** hard refresh → PIANO ROLL — tên track nằm cân bằng giữa nút (26px); status bar cuối không còn "Ctrl+Scroll: Playhead" — gợi ý adaptive hiển thị đầy đủ.
### [2026-08-07 09:50] Task: FIX Follow playhead chưa hoạt động — dùng sai isPlaying (main)
- **Báo cáo user:** follow playhead (0940) vẫn chưa thực hiện được.
- **Nguyên nhân:** effect dùng prop `isPlaying` = MAIN play — piano roll play chỉ set `st.isPlaying` (main vẫn false — handlePlayPause sub-tab stopAllPlayback) → effect luôn rơi nhánh "stop" → scroll về đầu mãi.
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.