24 Commits

Author SHA1 Message Date
3dtours 8fdf93f5d1 fix: đã sửa lỗi MIDI note ghi âm xuất hiện realtime 2026-07-25 20:04:42 +07:00
3dtours 8849f33d07 fix: extend piano roll playhead duration to 10min during recording 2026-07-25 20:03:09 +07:00
3dtours 97855f47d1 fix: add try-catch + nextMetronomeBeatRef init to startPianoRollRecording 2026-07-25 19:56:19 +07:00
3dtours d858628a8f fix: sync notes from st.notes + set isPlaying for playhead + metronome count-in 2026-07-25 19:53:45 +07:00
3dtours f3ad111376 fix: set subTab buffer + fix double stop() call + realtime playhead 2026-07-25 19:48:00 +07:00
3dtours 7f681f23c1 fix: không ghi âm và ghi note MIDI và khong xuất hiện realtime 2026-07-25 19:47:17 +07:00
3dtours 6cc3ae6ab8 fix: use startSubTabPlayback for piano roll recording + pass recTempMidiNotes prop 2026-07-25 19:39:55 +07:00
3dtours 432dec635a fix: không ghi âm và ghi note MIDI 2026-07-25 19:36:56 +07:00
3dtours e6dd7ff13c fix: realtime note display + MIDI key sound via refs 2026-07-25 18:58:30 +07:00
3dtours da760dbb86 fix: MIDI record + key highlight on MIDI key press 2026-07-25 18:55:31 +07:00
3dtours c3edda5c81 fix: call midiRec.start() so MIDI recorder receives messages 2026-07-25 18:44:33 +07:00
3dtours 68eb6d5261 fix: replace stale snapToScale ref with st.snapToScale 2026-07-25 18:39:47 +07:00
3dtours 513a6b082c fix: record midi - setSelectedMidiInputId, snapToScale persist, space stops recording 2026-07-25 18:37:02 +07:00
3dtours 1151f1afa0 feat: bật tính năng ghi midi note trong piano roll tab 2026-07-25 18:36:11 +07:00
3dtours 5af8536e53 fix: add recTempMidiNotes to PianoRollTabEditor props 2026-07-25 18:31:35 +07:00
3dtours dffb8562df fix: piano roll recording - playhead, realtime notes, recording display 2026-07-25 18:30:16 +07:00
3dtours 9498a0d4b0 fix: piano roll record via ARM + INPUT routes to piano roll tab 2026-07-25 18:25:29 +07:00
3dtours 2c91c0156a feat: add Omni MIDI option in piano roll INPUT select 2026-07-25 18:20:42 +07:00
3dtours cdff404437 fix: lỗi không mở được piano roll và thêm các tính năng cho AI 2026-07-25 17:29:35 +07:00
3dtours 1b711ef014 feat: piano roll transport, ARM/INPUT, AI bar range, MIDI recording
- Transport: play/stop/back/next/record buttons in piano roll toolbar
- ARM toggle + MIDI input device selector next to Snap to Scale
- AI bar range selector (bar X to bar Y) for Copilot MIDI generation
- handleAISend detects active piano roll tab and routes MIDI prompt
- Added selectedMidiInputId state + handleMidiInputSelect for MIDI input
2026-07-25 17:22:24 +07:00
3dtours 1a97a4b33b fix: quy định kết quả AI copilot trả về đúng tab đang mở 2026-07-25 16:35:54 +07:00
3dtours a59a36ec90 fix: use configured AI provider for piano roll prompt, not hardcoded
Piano roll AI prompt now selects provider via same logic as main AI
handler (aiProviders, selectedProviderId, baseUrl, apiKey, model).
No longer hardcodes provider:'openai', model:'gpt-4o'.
2026-07-25 16:26:47 +07:00
3dtours 7dd6300500 fix: guard AI response parse against undefined textResponse 2026-07-25 16:21:59 +07:00
3dtours 5628802e08 refactor: remove piano roll AI prompt, reuse Copilot for MIDI gen
- Removed aiPrompt/aiLoading state and handleAIPrompt from PianoRollTabEditor
- Removed AI prompt input UI from piano roll toolbar
- handleAISend now detects active piano roll tab and routes
  prompts to MIDI note generation, appends notes to the tab
2026-07-25 16:19:44 +07:00
5 changed files with 306 additions and 111 deletions
+282 -97
View File
@@ -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.
View File
+6
View File
@@ -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.
---