IMPROVE: thêm các tính năng cho MIDI trong PIANO ROLL TAB

This commit is contained in:
2026-08-07 20:46:47 +07:00
parent c3182f6baa
commit 45ab31d42e
4 changed files with 460 additions and 60 deletions
+386 -48
View File
@@ -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 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
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;
};
// 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 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 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;
}
@@ -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) {
// V TT 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 (i !== candidateIdx) return n;
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, candidateIdx];
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
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608070820" defer></script>
<script src="/static/js/app.precompiled.js?v=202608070840" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+39
View File
@@ -2783,3 +2783,42 @@
- **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).
- **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.