IMPROVE: thêm các tính năng cho MIDI trong PIANO ROLL TAB
This commit is contained in:
+392
-54
@@ -7109,11 +7109,54 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
||||
e.preventDefault();
|
||||
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)
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !inField) {
|
||||
if (e.key === 'c') {
|
||||
e.preventDefault();
|
||||
if (notes && notes.length) {
|
||||
if (onCopyNotes) onCopyNotes(notes);
|
||||
showToast('Đã copy ' + notes.length + ' nốt vào clipboard.', 'success');
|
||||
} else { showToast('Không có nốt để copy.', 'warning'); }
|
||||
return;
|
||||
}
|
||||
if (e.key === 'x') {
|
||||
e.preventDefault();
|
||||
if (notes && notes.length) {
|
||||
if (onCopyNotes) onCopyNotes(notes);
|
||||
pushToUndo(notes);
|
||||
setNotes([]);
|
||||
showToast('Đã cắt ' + notes.length + ' nốt.', 'success');
|
||||
} else { showToast('Không có nốt để cắt.', '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);
|
||||
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.', '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);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [notes, setSelectedNoteIds]);
|
||||
}, [notes, setSelectedNoteIds, clipboardNotes]);
|
||||
|
||||
// Undo/redo stacks
|
||||
const undoStackRef = React.useRef([]);
|
||||
@@ -7390,11 +7433,15 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Scale highlight: rows thuộc 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
|
||||
for (let pitch = 0; pitch < 128; pitch++) {
|
||||
const y = (127 - pitch) * NoteHeight;
|
||||
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.strokeStyle = '#2d2d35';
|
||||
@@ -7883,6 +7930,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
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)
|
||||
pushToUndo(notes);
|
||||
const start = getSnapBeat(beat, snapValue);
|
||||
@@ -8232,6 +8285,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
return nearest;
|
||||
};
|
||||
|
||||
// TẤT CẢ notes bắt đầu tại cùng beat (chord — velocity bars CÙNG cột) —
|
||||
// user 08:25: ctrl+draw qua vị trí → vẽ lại TẤT 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 canvas = ccCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -8264,7 +8329,9 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [] };
|
||||
}
|
||||
} else {
|
||||
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] };
|
||||
// Không chọn notes: vẽ TẤT CẢ notes cùng beat (chord — user 08:25)
|
||||
const idxs = findCCNoteIndicesAtBeat(beat);
|
||||
ccDragRef.current = { active: true, lastBeat: beat, lastPainted: idxs };
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -8318,19 +8385,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateIdx = findCCNoteIndex(beat, y, h);
|
||||
if (candidateIdx !== -1 && !painted.includes(candidateIdx)) {
|
||||
const currentVal = ccMode === 'pan' ? ((notes[candidateIdx].pan || 0) / 2.0 + 0.5) : (notes[candidateIdx].velocity !== undefined ? notes[candidateIdx].velocity : 0.8);
|
||||
if (Math.abs(currentVal - val) > 0.001) {
|
||||
const updatedNotes = notes.map((n, i) => {
|
||||
if (i !== candidateIdx) return n;
|
||||
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
|
||||
return { ...n, velocity: val };
|
||||
});
|
||||
setNotes(updatedNotes);
|
||||
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
|
||||
}
|
||||
drag.lastPainted = [...painted, candidateIdx];
|
||||
// Vẽ TẤT CẢ notes cùng beat (chord — user 08:25) — trước đây chỉ 1 note
|
||||
const candidateIdxs = findCCNoteIndicesAtBeat(beat);
|
||||
const unpainted = candidateIdxs.filter(ci => !painted.includes(ci));
|
||||
if (unpainted.length > 0) {
|
||||
const updatedNotes = notes.map((n, i) => {
|
||||
if (!unpainted.includes(i)) return n;
|
||||
if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 };
|
||||
return { ...n, velocity: val };
|
||||
});
|
||||
setNotes(updatedNotes);
|
||||
if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes);
|
||||
drag.lastPainted = [...painted, ...unpainted];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8458,6 +8524,17 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
const [selectedScale, setSelectedScale] = React.useState(null);
|
||||
const selectedScaleRef = React.useRef(null);
|
||||
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);
|
||||
snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true;
|
||||
const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
|
||||
@@ -8476,6 +8553,188 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
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 closeMenu = () => setScaleMenuPos(null);
|
||||
const origin = scaleMenuOriginRef.current || scaleMenuPos;
|
||||
@@ -8593,16 +8852,6 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
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-xs"
|
||||
}, React.createElement("span", {
|
||||
className: "text-zinc-500 font-semibold"
|
||||
}, "Snap:"), React.createElement("select", {
|
||||
value: snapValue,
|
||||
onChange: e => { onSnapChange(e.target.value); setRenderTick(t => t + 1); },
|
||||
@@ -8661,33 +8910,23 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
}), "Đó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'); }
|
||||
},
|
||||
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: "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');
|
||||
},
|
||||
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: "Paste notes từ clipboard vào tab này (append cuối)"
|
||||
}, "\uD83D\uDCE5 Paste"), React.createElement("div", {
|
||||
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,
|
||||
@@ -8714,11 +8953,89 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
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", {
|
||||
}, "🎵 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-xs"
|
||||
}, 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-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-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-[10px] 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-xs"
|
||||
}, 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-[10px] 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-xs"
|
||||
}, 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-[10px] 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-9 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-[10px] text-center"
|
||||
}), React.createElement("button", {
|
||||
onClick: () => applyVelocityNormalize(),
|
||||
title: "Normalize (max → 127)",
|
||||
className: "px-1.5 py-0.5 rounded text-[10px] bg-zinc-800 text-zinc-300 border border-zinc-700 hover:border-purple-600"
|
||||
}, "Norm")), React.createElement("div", {
|
||||
className: "flex items-center gap-1 ml-auto"
|
||||
}, React.createElement("button", {
|
||||
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"
|
||||
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"
|
||||
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
|
||||
onClick: () => {
|
||||
const ppq = 480;
|
||||
@@ -9008,6 +9325,27 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
})))),
|
||||
|
||||
/* 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()
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user