Compare commits
24 Commits
2fed3eee0b
...
8fdf93f5d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fdf93f5d1 | |||
| 8849f33d07 | |||
| 97855f47d1 | |||
| d858628a8f | |||
| f3ad111376 | |||
| 7f681f23c1 | |||
| 6cc3ae6ab8 | |||
| 432dec635a | |||
| e6dd7ff13c | |||
| da760dbb86 | |||
| c3edda5c81 | |||
| 68eb6d5261 | |||
| 513a6b082c | |||
| 1151f1afa0 | |||
| 5af8536e53 | |||
| dffb8562df | |||
| 9498a0d4b0 | |||
| 2c91c0156a | |||
| cdff404437 | |||
| 1b711ef014 | |||
| 1a97a4b33b | |||
| a59a36ec90 | |||
| 7dd6300500 | |||
| 5628802e08 |
+282
-97
@@ -4543,11 +4543,13 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
}, "Đóng"))));
|
||||
};
|
||||
|
||||
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast }) => {
|
||||
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches }) => {
|
||||
const [activeRollTool, setActiveRollTool] = React.useState('select');
|
||||
const [snapVal, setSnapVal] = React.useState('1/16');
|
||||
const [ccMode, setCcMode] = React.useState('velocity');
|
||||
const [rollZoom, setRollZoom] = React.useState(60); // local horizontal zoom factor
|
||||
const [aiBarStart, setAiBarStart] = React.useState(0);
|
||||
const [aiBarEnd, setAiBarEnd] = React.useState(4);
|
||||
|
||||
const canvasRef = React.useRef(null);
|
||||
const ccCanvasRef = React.useRef(null);
|
||||
@@ -4581,6 +4583,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
const viewBeats = Math.ceil(viewWidth / pixelsPerBeat) + 4;
|
||||
|
||||
const [notes, setNotes] = React.useState(st.notes || []);
|
||||
React.useEffect(() => { setNotes(st.notes || []); }, [st.notes]);
|
||||
const brushVelocityRef = React.useRef(0.8);
|
||||
const [selectedNoteIds, setSelectedNoteIds] = React.useState([]);
|
||||
|
||||
@@ -4808,6 +4811,23 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2);
|
||||
});
|
||||
|
||||
// Draw real-time recording notes
|
||||
if (recordingState === 'RECORDING' && recTempMidiNotes && recTempMidiNotes.length > 0) {
|
||||
recTempMidiNotes.forEach(note => {
|
||||
const x = note.start_beat * pixelsPerBeat;
|
||||
const y = (127 - note.pitch) * NoteHeight;
|
||||
const w = (note.duration_beats || 0.25) * pixelsPerBeat;
|
||||
ctx.fillStyle = 'rgba(255, 100, 100, 0.35)';
|
||||
ctx.strokeStyle = '#ff6464';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillRect(x + 1, y + 1, Math.max(2, w - 2), NoteHeight - 2);
|
||||
ctx.strokeRect(x + 1, y + 1, Math.max(2, w - 2), NoteHeight - 2);
|
||||
const vel = Math.min(1, note.velocity || 0.8);
|
||||
ctx.fillStyle = '#ff6464';
|
||||
ctx.fillRect(x + 1, y + 1, Math.max(2, (w - 2) * vel), NoteHeight - 2);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw selection marquee if active
|
||||
if (selectionMarquee) {
|
||||
const minBeat = Math.min(selectionMarquee.startBeat, selectionMarquee.currentBeat);
|
||||
@@ -4842,7 +4862,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats]);
|
||||
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = ccCanvasRef.current;
|
||||
@@ -5362,7 +5382,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
/*#__PURE__*/React.createElement("div", {
|
||||
key: pitch,
|
||||
style: { height: `${NoteHeight}px` },
|
||||
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800' : 'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,
|
||||
className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches && activeMidiPitches.has(pitch) ? 'bg-emerald-500 text-white border-emerald-400' : isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800' : 'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,
|
||||
onMouseDown: (e) => {
|
||||
e.stopPropagation();
|
||||
keybedMouseDownRef.current = true;
|
||||
@@ -5464,13 +5484,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
const [selectedScale, setSelectedScale] = React.useState(null);
|
||||
const selectedScaleRef = React.useRef(null);
|
||||
selectedScaleRef.current = selectedScale;
|
||||
const [snapToScale, setSnapToScale] = React.useState(true);
|
||||
const snapToScaleRef = React.useRef(true);
|
||||
snapToScaleRef.current = snapToScale;
|
||||
snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true;
|
||||
const [scaleMenuPos, setScaleMenuPos] = React.useState(null);
|
||||
const scaleMenuOriginRef = React.useRef(null);
|
||||
const [aiPrompt, setAiPrompt] = React.useState('');
|
||||
const [aiLoading, setAiLoading] = React.useState(false);
|
||||
const [showCC, setShowCC] = React.useState(true);
|
||||
const [ccHeight, setCcHeight] = React.useState(80);
|
||||
|
||||
@@ -5485,52 +5502,6 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
return octave * 12 + best;
|
||||
};
|
||||
|
||||
const handleAIPrompt = async () => {
|
||||
const prompt = aiPrompt.trim();
|
||||
if (!prompt || !window.AIGateway) return;
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const result = await window.AIGateway.executeAIPrompt({
|
||||
prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt,
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
systemInstruction: 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'
|
||||
});
|
||||
let notesData = null;
|
||||
if (result.textResponse) {
|
||||
try {
|
||||
const cleaned = result.textResponse.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
|
||||
notesData = JSON.parse(cleaned);
|
||||
} catch (e1) {}
|
||||
}
|
||||
if (!notesData && result.functionCalls) {
|
||||
for (const fc of result.functionCalls) {
|
||||
if (fc.arguments && fc.arguments.notes) { notesData = fc.arguments.notes; break; }
|
||||
}
|
||||
}
|
||||
if (Array.isArray(notesData) && notesData.length > 0) {
|
||||
const newNotes = notesData.map((n, i) => ({
|
||||
id: 'note_ai_' + Date.now() + '_' + i,
|
||||
pitch: Math.max(0, Math.min(127, n.pitch || 60)),
|
||||
start_beat: Math.max(0, parseFloat(n.start_beat) || 0),
|
||||
duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25),
|
||||
velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)),
|
||||
pan: 0.0
|
||||
}));
|
||||
pushToUndo(notes);
|
||||
setNotes(prev => [...prev, ...newNotes]);
|
||||
setSelectedNoteIds(newNotes.map(n => n.id));
|
||||
if (window.SonicSF && newNotes.length > 0) {
|
||||
const ctx = getAudioContext();
|
||||
newNotes.forEach(n => window.SonicSF.playNote(n.pitch, n.velocity * 127, 300, ctx.currentTime + (n.start_beat * 0.01), st.instrumentProgram, null));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI Piano Roll error:', err);
|
||||
}
|
||||
setAiLoading(false);
|
||||
};
|
||||
|
||||
const renderScaleContextMenu = () => {
|
||||
const closeMenu = () => setScaleMenuPos(null);
|
||||
const origin = scaleMenuOriginRef.current || scaleMenuPos;
|
||||
@@ -5626,11 +5597,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-zinc-500 font-semibold"
|
||||
}, "Snap to Scale"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setSnapToScale(!snapToScale),
|
||||
className: `w-7 h-4 rounded-full transition-colors relative ${snapToScale ? 'bg-yellow-600' : 'bg-zinc-700'}`,
|
||||
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 }
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${snapToScale ? 'translate-x-3.5' : 'translate-x-0.5'}`
|
||||
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'}`
|
||||
}))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-1 text-xs"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
@@ -5642,7 +5613,43 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
}, ['free', '4', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => /*#__PURE__*/React.createElement("option", {
|
||||
key: v,
|
||||
value: v
|
||||
}, v)))), /*#__PURE__*/React.createElement("div", {
|
||||
}, v)))), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isArmed: !s.isArmed } : s)),
|
||||
className: `px-2 py-1 rounded text-xs font-bold ${st.isArmed ? 'bg-red-600 text-white' : 'bg-zinc-800 text-zinc-400'}`
|
||||
}, "ARM"), /*#__PURE__*/React.createElement("select", {
|
||||
value: selectedMidiInputId || '',
|
||||
onChange: e => onMidiInputSelect(e.target.value),
|
||||
className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]"
|
||||
}, /*#__PURE__*/React.createElement("option", { value: "" }, "Input"), /*#__PURE__*/React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => /*#__PURE__*/React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-0.5 ml-1"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: Math.max(0, (st.currentTime || 0) - beatSec * 4) } : s)),
|
||||
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
|
||||
title: "Back 1 bar"
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-back", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: onPlayPause,
|
||||
className: `w-6 h-6 flex items-center justify-center rounded border ${isPlaying ? 'bg-emerald-600 text-black' : 'bg-cyan-600 text-white'} border-cyan-500`,
|
||||
title: isPlaying ? "Pause" : "Play"
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": isPlaying ? "pause" : "play", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: onStop,
|
||||
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
|
||||
title: "Stop"
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "square", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: onRecord,
|
||||
className: `w-6 h-6 flex items-center justify-center rounded border ${recordingState === 'RECORDING' ? 'bg-red-600 text-white border-red-500 animate-pulse' : 'bg-zinc-800 text-red-500 border-zinc-700'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: "w-3 h-3 fill-current" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: (st.currentTime || 0) + beatSec * 4 } : s)),
|
||||
className: "w-6 h-6 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700",
|
||||
title: "Forward 1 bar"
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-forward", className: "w-3 h-3" }))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-1 ml-1 text-xs"
|
||||
}, /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "AI:"), /*#__PURE__*/React.createElement("input", {
|
||||
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
|
||||
className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"
|
||||
}), /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "-"), /*#__PURE__*/React.createElement("input", {
|
||||
type: "number", value: aiBarEnd, onChange: e => setAiBarEnd(parseInt(e.target.value) || 1),
|
||||
className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center"
|
||||
}), /*#__PURE__*/React.createElement("span", { className: "text-zinc-500" }, "bar")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs"
|
||||
}, ['velocity', 'pan'].map(mode => /*#__PURE__*/React.createElement("button", {
|
||||
key: mode,
|
||||
@@ -5652,27 +5659,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
onClick: () => setShowCC(!showCC),
|
||||
className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}`
|
||||
}, ccMode === 'pan' ? 'Pan' : 'Vel'), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-1 flex-1 max-w-[300px] ml-2"
|
||||
}, /*#__PURE__*/React.createElement("input", {
|
||||
type: "text",
|
||||
value: aiPrompt,
|
||||
onChange: e => setAiPrompt(e.target.value),
|
||||
onKeyDown: e => { if (e.key === 'Enter') handleAIPrompt(); },
|
||||
placeholder: "AI: tạo 8 bars MIDI...",
|
||||
className: "flex-1 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] rounded px-2 py-1 outline-none focus:border-amber-500 min-w-0"
|
||||
}), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: handleAIPrompt,
|
||||
disabled: aiLoading,
|
||||
className: "px-2 py-1 text-[10px] bg-purple-700 hover:bg-purple-600 disabled:bg-zinc-700 text-white rounded flex items-center gap-1 transition"
|
||||
}, aiLoading ? "..." : "AI")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex items-center gap-1"
|
||||
}, /*#__PURE__*/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"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "save",
|
||||
className: "w-3 h-3"
|
||||
}), "Lưu"), /*#__PURE__*/React.createElement("button", {
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), /*#__PURE__*/React.createElement("button", {
|
||||
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"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
@@ -6267,6 +6258,13 @@ const App = () => {
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
const [audioDevices, setAudioDevices] = useState([]);
|
||||
const [midiDevices, setMidiDevices] = useState([]);
|
||||
const [selectedMidiInputId, setSelectedMidiInputId] = useState('');
|
||||
const handleMidiInputSelect = (id) => {
|
||||
setSelectedMidiInputId(id);
|
||||
if (window.SonicRecorderManager) {
|
||||
window.SonicRecorderManager.setSelectedMidiInputId(id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
|
||||
@@ -6282,14 +6280,24 @@ const App = () => {
|
||||
input.onmidimessage = msg => {
|
||||
console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`, Array.from(msg.data));
|
||||
if (msg.data.length < 3) return;
|
||||
const cmd = msg.data[0] >> 4;
|
||||
const pitch = msg.data[1];
|
||||
const velocity = msg.data[2];
|
||||
if (cmd === 0x9 && velocity > 0) {
|
||||
lastMidiNoteRef.current = { pitch, velocity, startTime: performance.now(), length: 0 };
|
||||
setLastMidiNote({ pitch, velocity, length: 0, time: Date.now() });
|
||||
} else if (cmd === 0x8 || (cmd === 0x9 && velocity === 0)) {
|
||||
const current = lastMidiNoteRef.current;
|
||||
const cmd = msg.data[0] >> 4;
|
||||
const pitch = msg.data[1];
|
||||
const velocity = msg.data[2];
|
||||
if (cmd === 0x9 && velocity > 0) {
|
||||
lastMidiNoteRef.current = { pitch, velocity, startTime: performance.now(), length: 0 };
|
||||
setLastMidiNote({ pitch, velocity, length: 0, time: Date.now() });
|
||||
activeMidiPitchesRef.current.add(pitch);
|
||||
setActiveMidiPitches(new Set(activeMidiPitchesRef.current));
|
||||
try {
|
||||
const ar = activeTabRef && subTabsRef && subTabsRef.current.find(s => s.id === activeTabRef.current && s.type === 'PIANO_ROLL' && s.isArmed);
|
||||
if (ar && window.SonicSF) {
|
||||
window.SonicSF.playNote(pitch, velocity, 500, undefined, ar.instrumentProgram, null);
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (cmd === 0x8 || (cmd === 0x9 && velocity === 0)) {
|
||||
activeMidiPitchesRef.current.delete(pitch);
|
||||
setActiveMidiPitches(new Set(activeMidiPitchesRef.current));
|
||||
const current = lastMidiNoteRef.current;
|
||||
if (current && current.pitch === pitch) {
|
||||
const lenSec = (performance.now() - current.startTime) / 1000;
|
||||
lastMidiNoteRef.current = { ...current, length: lenSec };
|
||||
@@ -6331,6 +6339,9 @@ const App = () => {
|
||||
|
||||
const activeMIDIRecordersRef = useRef({});
|
||||
const activeAudioRecordersRef = useRef({});
|
||||
const pianoRollRecorderRef = useRef(null);
|
||||
const activeMidiPitchesRef = useRef(new Set());
|
||||
const [activeMidiPitches, setActiveMidiPitches] = useState(new Set());
|
||||
const recordingPCMDataRef = useRef({});
|
||||
const recordingStartTimeRef = useRef(0);
|
||||
const recordingSyncRef = useRef(null);
|
||||
@@ -7136,6 +7147,10 @@ const App = () => {
|
||||
// Global space play/pause shortcut for transport
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
|
||||
handleRecordClick();
|
||||
return;
|
||||
}
|
||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||
return;
|
||||
}
|
||||
@@ -9163,6 +9178,7 @@ const App = () => {
|
||||
const notes = st.notes || [];
|
||||
const bpmVal = parseInt(bpmRef?.current || bpm) || 120;
|
||||
const beatSec = 60.0 / bpmVal;
|
||||
if (recordingStateRef.current === 'RECORDING') return 600.0; // 10 min during recording
|
||||
let maxEnd = 0;
|
||||
notes.forEach(n => {
|
||||
const end = (n.start_beat || 0) + (n.duration_beats || 1);
|
||||
@@ -9793,6 +9809,24 @@ const App = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if piano roll tab is active and armed
|
||||
const activePianoRoll = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isArmed);
|
||||
if (activePianoRoll && selectedMidiInputId) {
|
||||
setRecordingState('COUNT_IN');
|
||||
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
|
||||
const countInDuration = secondsPerBeat * 4;
|
||||
const audioCtx = getAudioContext();
|
||||
const now = audioCtx.currentTime;
|
||||
showToast('Metronome Count-in: 4... 3... 2... 1... (MIDI Piano Roll)', 'info');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
playMetronomeClick(now + i * secondsPerBeat, i === 0);
|
||||
}
|
||||
setTimeout(() => {
|
||||
startPianoRollRecording(activePianoRoll);
|
||||
}, countInDuration * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const armed = activeTracks.filter(t => t.isArmed && t.inputSource?.deviceType && t.inputSource.deviceType !== 'NONE');
|
||||
if (armed.length === 0) {
|
||||
showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.', 'warning');
|
||||
@@ -9816,6 +9850,67 @@ const App = () => {
|
||||
}, countInDuration * 1000);
|
||||
};
|
||||
|
||||
const startPianoRollRecording = (tab) => {
|
||||
try {
|
||||
const context = getAudioContext();
|
||||
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
|
||||
const startTime = currentTime;
|
||||
const startBeat = startTime / secondsPerBeat;
|
||||
nextMetronomeBeatRef.current = Math.ceil(startBeat);
|
||||
const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4);
|
||||
midiRec.tempTabId = tab.id;
|
||||
midiRec.selectedMidiInputId = selectedMidiInputId || 'ALL';
|
||||
midiRec.start(startTime / secondsPerBeat, midiRec.selectedMidiInputId);
|
||||
pianoRollRecorderRef.current = midiRec;
|
||||
activeMIDIRecordersRef.current['piano_roll'] = midiRec;
|
||||
setRecStartTimelineTime(startTime);
|
||||
recordingStartTimeRef.current = startTime;
|
||||
setRecordingState('RECORDING');
|
||||
setRecTempMidiNotes([]);
|
||||
const ctx = getAudioContext();
|
||||
const silentBuf = ctx.createBuffer(1, 128, ctx.sampleRate);
|
||||
setSubTabs(prev => prev.map(s => s.id === tab.id ? { ...s, buffer: silentBuf, isPlaying: true } : s));
|
||||
startSubTabPlayback({ ...tab, buffer: silentBuf }, startTime);
|
||||
startOffsetTimeRef.current = startTime;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
setIsPlaying(true);
|
||||
midiRec.onNoteOn = (pitch, currentBeat) => {
|
||||
const elapsedBeats = Math.max(0, currentBeat);
|
||||
const sec = elapsedBeats * (60.0 / (parseInt(bpm) || 120));
|
||||
const activeNotes = Array.from(midiRec.activeNotes.values()).map(n => ({
|
||||
id: 'rec_' + n.pitch + '_' + currentBeat,
|
||||
pitch: n.pitch,
|
||||
start_beat: n.start_beat,
|
||||
duration_beats: Math.max(0.125, currentBeat - n.start_beat),
|
||||
velocity: Math.min(1, (n.velocity || 0.8)),
|
||||
pan: 0.0
|
||||
}));
|
||||
const rec = midiRec.recordedNotes.map((n, i) => ({
|
||||
id: 'rec_' + Date.now() + '_' + i,
|
||||
pitch: n.pitch,
|
||||
start_beat: n.start_beat,
|
||||
duration_beats: n.duration_beats,
|
||||
velocity: Math.min(1, (n.velocity || 0.8)),
|
||||
pan: 0.0
|
||||
}));
|
||||
const allNotes = [...rec, ...activeNotes];
|
||||
setRecTempMidiNotes(allNotes);
|
||||
setCanvasRedrawCount(n => n + 1);
|
||||
setSubTabs(prev => prev.map(s =>
|
||||
s.id === tab.id ? { ...s, currentTime: startTime + sec, isDirty: true } : s
|
||||
));
|
||||
};
|
||||
if (!tab._recordingStarted) {
|
||||
tab._recordingStarted = true;
|
||||
}
|
||||
showToast('Recording MIDI to Piano Roll...', 'info');
|
||||
} catch (err) {
|
||||
console.error('startPianoRollRecording error:', err);
|
||||
showToast('Lỗi khi bắt đầu ghi âm Piano Roll: ' + err.message, 'warning');
|
||||
setRecordingState('IDLE');
|
||||
}
|
||||
};
|
||||
|
||||
const startRecordingTake = async (armedTracks) => {
|
||||
const context = getAudioContext();
|
||||
if (context.state === 'suspended') {
|
||||
@@ -10002,9 +10097,24 @@ const App = () => {
|
||||
|
||||
for (let trackId in midiRecorders) {
|
||||
const midiRec = midiRecorders[trackId];
|
||||
const recordedNotes = midiRec.stop();
|
||||
|
||||
if (midiRec.tempMidiItemId) {
|
||||
if (midiRec.tempTabId) {
|
||||
const recordedNotes = midiRec.stop();
|
||||
if (recordedNotes.length > 0) {
|
||||
const newNotes = recordedNotes.map((n, i) => ({
|
||||
id: 'rec_' + Date.now() + '_' + i,
|
||||
pitch: n.pitch,
|
||||
start_beat: Math.max(0, n.start_beat || 0),
|
||||
duration_beats: Math.max(0.125, n.duration_beats || 0.25),
|
||||
velocity: Math.min(1, (n.velocity || 0.8)),
|
||||
pan: 0.0
|
||||
}));
|
||||
setSubTabs(prev => prev.map(s => s.id === midiRec.tempTabId ? { ...s, notes: [...(s.notes || []), ...newNotes], isDirty: true } : s));
|
||||
setCanvasRedrawCount(n => n + 1);
|
||||
showToast(`Đã ghi ${recordedNotes.length} notes vào Piano Roll.`, 'success');
|
||||
}
|
||||
hasRecordedAnything = true;
|
||||
} else if (midiRec.tempMidiItemId) {
|
||||
const recCurrentTimeSec = Math.max(0, context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec);
|
||||
const recElapsedBeats = recCurrentTimeSec / (60.0 / midiRec.bpm);
|
||||
const totalDurationBeats = Math.max(4.0, recordedNotes.length > 0 ? Math.max(recElapsedBeats, ...recordedNotes.map(n => n.start_beat + n.duration_beats)) : recElapsedBeats);
|
||||
@@ -12296,6 +12406,73 @@ const App = () => {
|
||||
const handleAISend = async () => {
|
||||
const prompt = aiPrompt.trim();
|
||||
if (!prompt) { showToast('Vui lòng nhập nội dung prompt.', 'warning'); return; }
|
||||
|
||||
// Check if piano roll tab is active → route prompt to MIDI generation
|
||||
const activePianoRoll = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL');
|
||||
if (activePianoRoll) {
|
||||
setAiProcessing(true);
|
||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI cho Piano Roll...`, time: Date.now() }]);
|
||||
try {
|
||||
if (aiProviders.length === 0 || !selectedProviderId) {
|
||||
try {
|
||||
const data = await window.SonicAPI.getAIConfigs();
|
||||
if (data && data.providers && data.providers.length > 0) {
|
||||
setAiProviders(data.providers);
|
||||
const active = data.providers.find(p => p.is_active) || data.providers[0];
|
||||
if (active) setSelectedProviderId(active.id);
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
const prv = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null);
|
||||
const provider = prv || aiConfig;
|
||||
const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`;
|
||||
const apiKey = provider.api_key || provider.apiKey || '';
|
||||
const model = provider.model_name || provider.model || 'deepseek-chat';
|
||||
setAiActionLog(prev => [...prev, { type: 'info', text: ` Provider: ${provider.name || 'default'} | Model: ${model}`, time: Date.now() }]);
|
||||
const result = await window.AIGateway.executeAIPrompt({
|
||||
prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt,
|
||||
provider: provider.name || 'default',
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
||||
systemInstruction: 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'
|
||||
});
|
||||
if (!result) throw new Error('AI không phản hồi');
|
||||
let notesData = null;
|
||||
const textResp = result.textResponse || result.text || '';
|
||||
if (textResp && typeof textResp === 'string') {
|
||||
try {
|
||||
const cleaned = textResp.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
|
||||
notesData = JSON.parse(cleaned);
|
||||
} catch (e1) { console.error('Parse notes error:', e1); }
|
||||
}
|
||||
if (!notesData && result.functionCalls) {
|
||||
for (const fc of result.functionCalls) {
|
||||
if (fc.arguments && fc.arguments.notes) { notesData = fc.arguments.notes; break; }
|
||||
}
|
||||
}
|
||||
if (Array.isArray(notesData) && notesData.length > 0) {
|
||||
const newNotes = notesData.map((n, i) => ({
|
||||
id: 'note_ai_' + Date.now() + '_' + i,
|
||||
pitch: Math.max(0, Math.min(127, n.pitch || 60)),
|
||||
start_beat: Math.max(0, parseFloat(n.start_beat) || 0),
|
||||
duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25),
|
||||
velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)),
|
||||
pan: 0.0
|
||||
}));
|
||||
setSubTabs(prev => prev.map(s => s.id === activePianoRoll.id ? { ...s, notes: [...(s.notes || []), ...newNotes], isDirty: true } : s));
|
||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ Đã thêm ${newNotes.length} notes vào Piano Roll`, time: Date.now() }]);
|
||||
setCanvasRedrawCount(n => n + 1);
|
||||
} else {
|
||||
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ AI không trả về notes hợp lệ.`, time: Date.now() }]);
|
||||
}
|
||||
} catch (err) {
|
||||
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ Lỗi: ${err.message}`, time: Date.now() }]);
|
||||
}
|
||||
setAiProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.DAWCommandDispatcher) {
|
||||
window.DAWCommandDispatcher.currentSelectedTrackId = selectedTrackId;
|
||||
window.DAWCommandDispatcher.currentTracks = activeTracks;
|
||||
@@ -15255,19 +15432,27 @@ const App = () => {
|
||||
if (!st) return null;
|
||||
if (st.type === 'PIANO_ROLL') {
|
||||
return /*#__PURE__*/React.createElement(PianoRollTabEditor, {
|
||||
st: st,
|
||||
zoom: zoom,
|
||||
bpm: bpm,
|
||||
viewportWidth: viewportWidth,
|
||||
onClose: () => closeSubTab(st.id),
|
||||
onUpdateNotes: handleUpdateMidiNotes,
|
||||
onSaveNotes: handleSaveMidiNotes,
|
||||
setSubTabs: setSubTabs,
|
||||
onPlayPause: handlePlayPause,
|
||||
onStop: stopAllPlayback,
|
||||
isPlaying: isPlaying,
|
||||
playPreviewNote: playMidiPreviewNote
|
||||
});
|
||||
st: st,
|
||||
zoom: zoom,
|
||||
bpm: bpm,
|
||||
viewportWidth: viewportWidth,
|
||||
onClose: () => closeSubTab(st.id),
|
||||
onUpdateNotes: handleUpdateMidiNotes,
|
||||
onSaveNotes: handleSaveMidiNotes,
|
||||
setSubTabs: setSubTabs,
|
||||
onPlayPause: handlePlayPause,
|
||||
onStop: stopAllPlayback,
|
||||
isPlaying: isPlaying,
|
||||
playPreviewNote: playMidiPreviewNote,
|
||||
showToast: showToast,
|
||||
midiDevices: midiDevices,
|
||||
recordingState: recordingState,
|
||||
recTempMidiNotes: recTempMidiNotes,
|
||||
onRecord: handleRecordClick,
|
||||
selectedMidiInputId: selectedMidiInputId,
|
||||
onMidiInputSelect: handleMidiInputSelect,
|
||||
activeMidiPitches: activeMidiPitches
|
||||
});
|
||||
}
|
||||
const subTrack = tracks.find(t => t.id === st.trackId);
|
||||
const vTrack = subTrack ? {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -133,3 +133,9 @@
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
### [2026-07-25 17:22] Task: Piano roll toolbar - transport, ARM/INPUT, AI bar range
|
||||
- **Tóm tắt thay đổi:** Thêm transport (play/stop/back/next/record), ARM+INPUT select MIDI, AI bar range (bar x-y) vào piano roll toolbar. handleAISend phát hiện piano roll active → MIDI prompt. Thêm selectedMidiInputId + handleMidiInputSelect.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user