diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 0f16ecb..3fa9d44 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3031,6 +3031,7 @@ const App = () => { const [numberBar, setNumberBar] = useState(1); const [subTabHeight, setSubTabHeight] = useState(96); const [isExporting, setIsExporting] = useState(false); + const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || ''); const [soloedTrackId, setSoloedTrackId] = useState(null); const [toastMessage, setToastMessage] = useState(null); const [showAIConfig, setShowAIConfig] = useState(false); @@ -3048,7 +3049,7 @@ const App = () => { ai: 'right', python_tools: 'bottom', selection: 'bottom', - media_explorer: 'right', + media_explorer: 'bottom', fx_rack: 'bottom', midi_events: 'bottom' }); @@ -3952,6 +3953,11 @@ const App = () => { return; } } + if (ctrl && !alt && e.key === 's') { + e.preventDefault(); + handleSaveProject(); + return; + } if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); @@ -6338,6 +6344,19 @@ const App = () => { clientSideExport(exportTracks); } }; + const handleSaveProject = async () => { + if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; } + let name = projectName; + if (!name) { name = prompt("Nhập tên dự án:", "Dự án SonicForge"); if (!name) return; } + setProjectName(name); + localStorage.setItem('sonic_project_name', name); + const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null })); + try { + const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name, tracks: ss(tracks) }); + await window.SonicAPI.saveCloudProject(name, dataJson); + showToast(`Đã lưu "${name}" lên server!`, "success"); + } catch (err) { showToast(err.message || "Lỗi lưu server", "error"); } + }; const handleSaveCloud = async () => { if (!currentUser) { setIsMandatoryLogin(false); @@ -7788,18 +7807,14 @@ const App = () => { action: () => handleImportSFS() }, { label: 'Save Project', - icon: 'save', + icon: 'upload-cloud', shortcut: 'Ctrl+S', - action: () => handleExportSFS() + action: () => handleSaveProject() }, { label: 'Save As...', - icon: 'save', + icon: 'download', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() - }, { - label: 'Save to Cloud', - icon: 'upload-cloud', - action: () => handleSaveCloud() }, { sep: true }, { @@ -8479,7 +8494,7 @@ const App = () => { addPanel('ai', panelPositions.ai, showAIPanel); addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); addPanel('selection', panelPositions.selection, showSelectionPanel); - addPanel('media_explorer', panelPositions.media_explorer || 'right', showMediaExplorer); + addPanel('media_explorer', 'bottom', showMediaExplorer); addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack); addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents); const closePanel = id => { @@ -8575,7 +8590,7 @@ const App = () => { className: "w-3 h-3" })), isExporting ? '...' : 'Export')); if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" + className: "flex flex-col gap-1.5 flex-1 min-h-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('ai', e) @@ -8630,6 +8645,43 @@ const App = () => { key: p.id, value: p.id }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { + className: "border-t border-zinc-800 pt-1 mt-1 shrink-0" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { + const entry = window.DAWCommandDispatcher.undo(); + if (entry) { + setAiActionLog(prev => [...prev, { type: 'undo', text: `Undo: ${entry.name}`, time: Date.now() }]); + showToast(`Undo AI: ${entry.name}`, 'info'); + } + } else { + handleUndo(); + setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]); + } + }, + className: "w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "rotate-ccw", + className: "w-3 h-3" + }), "Undo")), /*#__PURE__*/React.createElement("div", { + className: "flex-1 min-h-0 flex flex-col overflow-hidden mt-1" + }, /*#__PURE__*/React.createElement("div", { + className: "text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center gap-1" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "list", + className: "w-3 h-3" + }), " Action Log")), /*#__PURE__*/React.createElement("div", { + className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" + }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { + className: "text-xs text-zinc-600 italic select-text" + }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { + key: i, + className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` + }, new Date(entry.time).toLocaleTimeString(), entry.text)))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1.5 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" @@ -8666,8 +8718,8 @@ const App = () => { } } } - }), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1 mt-1" + })), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("button", { onClick: handleAISend, disabled: aiProcessing, @@ -8684,38 +8736,8 @@ const App = () => { }, className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" }, "Clear")), /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 mt-0.5" - }, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1 mt-1 flex-1 min-h-0 flex flex-col overflow-hidden" - }, /*#__PURE__*/React.createElement("div", { - className: "text-[10px] font-bold text-zinc-400 uppercase flex items-center justify-between shrink-0 pb-0.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center gap-1" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "list", - className: "w-3 h-3" - }), " Action Log"), aiActionLog.length > 0 && /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { - const entry = window.DAWCommandDispatcher.undo(); - if (entry) { - setAiActionLog(prev => [...prev, { type: 'undo', text: `Undo: ${entry.name}`, time: Date.now() }]); - showToast(`Undo AI: ${entry.name}`, 'info'); - } - } else { - handleUndo(); - setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]); - } - }, - className: "text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0" - }, "Undo"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" - }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 italic select-text" - }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { - key: i, - className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` - }, new Date(entry.time).toLocaleTimeString(), entry.text)))); + className: "text-xs text-zinc-600 shrink-0" + }, "Enter để gửi nhanh"))); if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { @@ -8950,10 +8972,7 @@ const App = () => { }, panels.map((p, idx) => /*#__PURE__*/React.createElement(React.Fragment, { key: p }, /*#__PURE__*/React.createElement("div", { - className: `${p === 'media_explorer' ? 'flex-shrink-0' : 'flex-1 min-h-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm`, - style: p === 'media_explorer' ? { - height: `${mediaExplorerHeight}%` - } : {} + className: 'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm' }, renderPanelContent(p)), idx < panels.length - 1 && /*#__PURE__*/React.createElement("div", { className: "h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0", onMouseDown: startRowResize diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 35e2715..de7fdc5 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -3074,6 +3074,7 @@ const App = () => { const [numberBar, setNumberBar] = useState(1); const [subTabHeight, setSubTabHeight] = useState(96); const [isExporting, setIsExporting] = useState(false); + const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || ''); const [soloedTrackId, setSoloedTrackId] = useState(null); const [toastMessage, setToastMessage] = useState(null); const [showAIConfig, setShowAIConfig] = useState(false); @@ -3091,7 +3092,7 @@ const App = () => { ai: 'right', python_tools: 'bottom', selection: 'bottom', - media_explorer: 'right', + media_explorer: 'bottom', fx_rack: 'bottom', midi_events: 'bottom' }); @@ -4001,6 +4002,11 @@ const App = () => { return; } } + if (ctrl && !alt && e.key === 's') { + e.preventDefault(); + handleSaveProject(); + return; + } if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); @@ -6442,6 +6448,50 @@ const App = () => { clientSideExport(exportTracks); } }; + const handleSaveProject = async () => { + if (!currentUser) { + setIsMandatoryLogin(false); + setAuthMode('login'); + setAuthModalOpen(true); + return; + } + let name = projectName; + if (!name) { + name = prompt("Nhập tên dự án:", "Dự án SonicForge"); + if (!name) return; + } + setProjectName(name); + localStorage.setItem('sonic_project_name', name); + const ss = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + try { + const dataJson = JSON.stringify({ + id: 'project_' + Date.now(), + name, + tracks: ss(tracks) + }); + await window.SonicAPI.saveCloudProject(name, dataJson); + showToast(`Đã lưu "${name}" lên server!`, "success"); + } catch (err) { + showToast(err.message || "Lỗi lưu server", "error"); + } + }; const handleSaveCloud = async () => { if (!currentUser) { setIsMandatoryLogin(false); @@ -8135,18 +8185,14 @@ const App = () => { action: () => handleImportSFS() }, { label: 'Save Project', - icon: 'save', + icon: 'upload-cloud', shortcut: 'Ctrl+S', - action: () => handleExportSFS() + action: () => handleSaveProject() }, { label: 'Save As...', - icon: 'save', + icon: 'download', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() - }, { - label: 'Save to Cloud', - icon: 'upload-cloud', - action: () => handleSaveCloud() }, { sep: true }, { @@ -8826,7 +8872,7 @@ const App = () => { addPanel('ai', panelPositions.ai, showAIPanel); addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); addPanel('selection', panelPositions.selection, showSelectionPanel); - addPanel('media_explorer', panelPositions.media_explorer || 'right', showMediaExplorer); + addPanel('media_explorer', 'bottom', showMediaExplorer); addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack); addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents); const closePanel = id => { @@ -8969,7 +9015,7 @@ const App = () => { className: "w-3 h-3" })), isExporting ? '...' : 'Export')); if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { - className: "flex flex-col h-full gap-1.5" + className: "flex flex-col gap-1.5 flex-1 min-h-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('ai', e) @@ -9024,6 +9070,51 @@ const App = () => { key: p.id, value: p.id }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { + className: "border-t border-zinc-800 pt-1 mt-1 shrink-0" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { + const entry = window.DAWCommandDispatcher.undo(); + if (entry) { + setAiActionLog(prev => [...prev, { + type: 'undo', + text: `Undo: ${entry.name}`, + time: Date.now() + }]); + showToast(`Undo AI: ${entry.name}`, 'info'); + } + } else { + handleUndo(); + setAiActionLog(prev => [...prev, { + type: 'undo', + text: 'Undo (Ctrl+Z)', + time: Date.now() + }]); + } + }, + className: "w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "rotate-ccw", + className: "w-3 h-3" + }), "Undo")), /*#__PURE__*/React.createElement("div", { + className: "flex-1 min-h-0 flex flex-col overflow-hidden mt-1" + }, /*#__PURE__*/React.createElement("div", { + className: "text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center gap-1" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "list", + className: "w-3 h-3" + }), " Action Log")), /*#__PURE__*/React.createElement("div", { + className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" + }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { + className: "text-xs text-zinc-600 italic select-text" + }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { + key: i, + className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` + }, new Date(entry.time).toLocaleTimeString(), entry.text)))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1.5 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" @@ -9060,8 +9151,8 @@ const App = () => { } } } - }), /*#__PURE__*/React.createElement("div", { - className: "flex items-center gap-1 mt-1" + })), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("button", { onClick: handleAISend, disabled: aiProcessing, @@ -9078,46 +9169,8 @@ const App = () => { }, className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" }, "Clear")), /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 mt-0.5" - }, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1 mt-1 flex-1 min-h-0 flex flex-col overflow-hidden" - }, /*#__PURE__*/React.createElement("div", { - className: "text-[10px] font-bold text-zinc-400 uppercase flex items-center justify-between shrink-0 pb-0.5" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center gap-1" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "list", - className: "w-3 h-3" - }), " Action Log"), aiActionLog.length > 0 && /*#__PURE__*/React.createElement("button", { - onClick: () => { - if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { - const entry = window.DAWCommandDispatcher.undo(); - if (entry) { - setAiActionLog(prev => [...prev, { - type: 'undo', - text: `Undo: ${entry.name}`, - time: Date.now() - }]); - showToast(`Undo AI: ${entry.name}`, 'info'); - } - } else { - handleUndo(); - setAiActionLog(prev => [...prev, { - type: 'undo', - text: 'Undo (Ctrl+Z)', - time: Date.now() - }]); - } - }, - className: "text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0" - }, "Undo"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" - }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 italic select-text" - }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { - key: i, - className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` - }, new Date(entry.time).toLocaleTimeString(), entry.text)))); + className: "text-xs text-zinc-600 shrink-0" + }, "Enter để gửi nhanh"))); if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { @@ -9352,10 +9405,7 @@ const App = () => { }, panels.map((p, idx) => /*#__PURE__*/React.createElement(React.Fragment, { key: p }, /*#__PURE__*/React.createElement("div", { - className: `${p === 'media_explorer' ? 'flex-shrink-0' : 'flex-1 min-h-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm`, - style: p === 'media_explorer' ? { - height: `${mediaExplorerHeight}%` - } : {} + className: 'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm' }, renderPanelContent(p)), idx < panels.length - 1 && /*#__PURE__*/React.createElement("div", { className: "h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0", onMouseDown: startRowResize diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 3186013..eaf9deb 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -3,249 +3,49 @@ const AIGateway = (function() { const DEFAULT_TOOLS = [{ - name: 'create_track', - description: 'Tạo một track âm thanh mới trong dự án', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'Tên cho track mới (VD: Beat, Vocal, Guitar)' }, - type: { type: 'string', enum: ['audio', 'midi'], description: 'Loại track' } - }, - required: ['name'] - } + name: 'set_selection', description: 'Chọn vùng timeline', parameters: { type: 'object', properties: { start_bar: { type: 'number' }, end_bar: { type: 'number' }, start_time: { type: 'number' }, end_time: { type: 'number' }, length_bars: { type: 'number' } } } }, { - name: 'delete_track', - description: 'Xóa một track khỏi dự án', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần xóa. Nếu không có thì xóa track đang chọn.' } - } - } + name: 'cut_audio', description: 'Cắt audio, snap zero-crossing, tạo track mới', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, end_bar: { type: 'number' }, length_bars: { type: 'number' }, snap_silence: { type: 'boolean' }, new_track_name: { type: 'string' } } } }, { - name: 'add_clip', - description: 'Thêm một clip âm thanh rỗng vào track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track đích' }, - start_time: { type: 'number', description: 'Vị trí bắt đầu (giây)' }, - duration_seconds: { type: 'number', description: 'Độ dài clip (giây)' }, - start_bar: { type: 'number', description: 'Vị trí bắt đầu (bar). Bar 0 = bar đầu tiên. Dùng thay cho start_time.' }, - length_bars: { type: 'number', description: 'Độ dài (bar). Dùng thay cho duration_seconds.' }, - name: { type: 'string', description: 'Tên clip' } - } - } + name: 'create_track', description: 'Tạo track mới', parameters: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string', enum: ['audio', 'midi'] } }, required: ['name'] } }, { - name: 'remove_clip', - description: 'Xóa một clip khỏi track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track' }, - clip_id: { type: 'string', description: 'ID của clip cần xóa' } - }, - required: ['clip_id'] - } + name: 'delete_track', description: 'Xóa track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } } }, { - name: 'set_track_volume', - description: 'Điều chỉnh âm lượng của track (dB)', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track' }, - volume_db: { type: 'number', description: 'Âm lượng tính bằng dB (VD: -6, 0, +3)' } - }, - required: ['volume_db'] - } + name: 'rename_track', description: 'Đổi tên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, name: { type: 'string' } }, required: ['track_id', 'name'] } }, { - name: 'set_track_pan', - description: 'Điều chỉnh cân bằng trái/phải (pan) của track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track' }, - pan: { type: 'integer', description: 'Pan value: -100 (trái), 0 (trung tâm), 100 (phải)' } - }, - required: ['pan'] - } + name: 'add_clip', description: 'Thêm clip rỗng vào track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, duration_seconds: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' }, name: { type: 'string' } } } }, { - name: 'toggle_mute', - description: 'Bật/tắt mute (tắt tiếng) của track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần mute/unmute' } - } - } + name: 'remove_clip', description: 'Xóa clip khỏi track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, clip_id: { type: 'string' } }, required: ['clip_id'] } }, { - name: 'toggle_solo', - description: 'Bật/tắt solo (chỉ nghe track này) của track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần solo/unsolo' } - } - } + name: 'set_track_volume', description: 'Chỉnh âm lượng dB', parameters: { type: 'object', properties: { track_id: { type: 'string' }, volume_db: { type: 'number' } }, required: ['volume_db'] } }, { - name: 'rename_track', - description: 'Đổi tên của một track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần đổi tên' }, - name: { type: 'string', description: 'Tên mới cho track' } - }, - required: ['track_id', 'name'] - } + name: 'set_track_pan', description: 'Chỉnh pan trái/phải', parameters: { type: 'object', properties: { track_id: { type: 'string' }, pan: { type: 'integer' } }, required: ['pan'] } }, { - name: 'set_bpm', - description: 'Thay đổi tempo (BPM) của dự án', - parameters: { - type: 'object', - properties: { - bpm: { type: 'number', description: 'Tempo mới tính bằng BPM (VD: 120, 128, 140)' } - }, - required: ['bpm'] - } + name: 'toggle_mute', description: 'Mute/unmute track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } } }, { - name: 'set_playhead', - description: 'Di chuyển playhead (con trỏ phát) đến vị trí chỉ định', - parameters: { - type: 'object', - properties: { - time: { type: 'number', description: 'Vị trí thời gian tính bằng giây' }, - bar: { type: 'number', description: 'Vị trí bar (0 = bar đầu tiên). Dùng thay cho time.' } - } - } + name: 'toggle_solo', description: 'Solo/unsolo track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } } }, { - name: 'add_marker', - description: 'Thêm một marker (đánh dấu) vào track tại vị trí chỉ định', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track' }, - time: { type: 'number', description: 'Vị trí thời gian (giây). Mặc định là vị trí playhead hiện tại.' }, - label: { type: 'string', description: 'Nhãn cho marker' } - } - } + name: 'set_bpm', description: 'Thay đổi BPM', parameters: { type: 'object', properties: { bpm: { type: 'number' } }, required: ['bpm'] } }, { - name: 'process_audio_dsp', - description: 'Xử lý hiệu ứng âm thanh DSP cho track (chuẩn hóa, đảo phase, gain, pitch shift)', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track âm thanh' }, - action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'], description: 'Loại xử lý DSP' }, - params: { type: 'object', description: 'Tham số bổ sung: gain_db cho gain, semitones cho pitch_shift' } - }, - required: ['track_id', 'action'] - } + name: 'set_playhead', description: 'Di chuyển playhead', parameters: { type: 'object', properties: { time: { type: 'number' }, bar: { type: 'number' } } } }, { - name: 'create_midi_item', - description: 'Thêm một MIDI item/clip vào track', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track đích' }, - start_bar: { type: 'number', description: 'Vị trí bắt đầu (tính bằng bar)' }, - length_bars: { type: 'number', description: 'Độ dài item (tính bằng bar)' } - }, - required: ['track_id', 'start_bar', 'length_bars'] - } + name: 'add_marker', description: 'Thêm marker', parameters: { type: 'object', properties: { track_id: { type: 'string' }, time: { type: 'number' }, label: { type: 'string' } } } }, { - name: 'set_selection', - description: 'Chọn một vùng trên timeline (selection range). Dùng để xác định khoảng thời gian trước khi gọi các lệnh khác.', - parameters: { - type: 'object', - properties: { - start_bar: { type: 'number', description: 'Bar bắt đầu (0 = bar đầu tiên của project)' }, - end_bar: { type: 'number', description: 'Bar kết thúc' }, - start_time: { type: 'number', description: 'Thời gian bắt đầu (giây). Dùng thay cho start_bar.' }, - end_time: { type: 'number', description: 'Thời gian kết thúc (giây). Dùng thay cho end_bar.' }, - length_bars: { type: 'number', description: 'Độ dài vùng chọn (bar). Dùng cùng start_bar thay cho end_bar.' } - } - } + name: 'process_audio_dsp', description: 'Xử lý DSP: normalize/invert/gain/pitch', parameters: { type: 'object', properties: { track_id: { type: 'string' }, action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'] }, params: { type: 'object' } }, required: ['track_id', 'action'] } }, { - name: 'select_item', - description: 'Chọn một item/clip/sample cụ thể trên track. Nếu không có track_id và item_name thì chọn toàn bộ timeline.', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track. Nếu không có thì dùng track đang chọn.' }, - item_name: { type: 'string', description: 'Tên của clip/sample cần chọn. VD: "Cut_Cartoon Capers Loop.mp3". Nếu không có thì chọn toàn bộ track.' }, - select_all: { type: 'boolean', description: 'Chọn toàn bộ timeline (bỏ qua track_id và item_name)' } - } - } + name: 'create_midi_item', description: 'Tạo MIDI item trên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['track_id', 'start_bar', 'length_bars'] } }, { - name: 'cut_audio', - description: 'Cắt một đoạn audio từ track nguồn. Tự động tạo selection, snap zero-crossing, và tạo track mới chứa đoạn cắt. Lệnh DUY NHẤT cho thao tác cắt - không cần gọi set_selection trước.', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track nguồn' }, - start_time: { type: 'number', description: 'Vị trí bắt đầu cắt (giây). Mặc định: dùng playhead hiện tại.' }, - end_time: { type: 'number', description: 'Vị trí kết thúc cắt (giây). Mặc định: dùng selection hiện tại.' }, - start_bar: { type: 'number', description: 'Bar bắt đầu cắt (0 = bar đầu). Dùng thay cho start_time.' }, - end_bar: { type: 'number', description: 'Bar kết thúc cắt. VD: end_bar=12 cắt đến bar 12. Dùng thay cho end_time.' }, - length_bars: { type: 'number', description: 'Độ dài cắt (bar). Dùng cùng start_bar thay cho end_bar.' }, - snap_silence: { type: 'boolean', description: 'Tự động snap vào điểm zero-crossing gần nhất ở hai đầu (mặc định: true)' }, - new_track_name: { type: 'string', description: 'Tên cho track mới. Mặc định: "Cut_"' } - } - } + name: 'modify_midi_notes', description: 'Sửa note MIDI trong item', parameters: { type: 'object', properties: { item_id: { type: 'string' }, notes: { type: 'array', items: { type: 'object', properties: { pitch: { type: 'string' }, start_time: { type: 'number' }, duration: { type: 'number' }, velocity: { type: 'integer', minimum: 0, maximum: 127 } }, required: ['pitch', 'start_time', 'duration'] } } }, required: ['item_id', 'notes'] } }, { - name: 'scan_track', - description: 'Quét và phân tích track âm thanh: phát hiện BPM (tempo) và tự động cập nhật tempo hệ thống, sample rate, số kênh (mono/stereo), duration', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần quét' }, - set_tempo: { type: 'boolean', description: 'Tự động cập nhật tempo hệ thống (mặc định: true)' } - } - } + name: 'select_item', description: 'Chọn clip/item theo tên', parameters: { type: 'object', properties: { track_id: { type: 'string' }, item_name: { type: 'string' }, select_all: { type: 'boolean' } } } }, { - name: 'modify_midi_notes', - description: 'Thêm, chỉnh sửa hoặc xóa các note MIDI trong item', - parameters: { - type: 'object', - properties: { - item_id: { type: 'string', description: 'ID của MIDI item' }, - notes: { - type: 'array', - description: 'Danh sách các note MIDI', - items: { - type: 'object', - properties: { - pitch: { type: 'string', description: 'VD: C4, D#3, F5' }, - start_time: { type: 'number', description: 'Thời điểm bắt đầu (bar hoặc giây)' }, - duration: { type: 'number', description: 'Độ dài note' }, - velocity: { type: 'integer', minimum: 0, maximum: 127, description: 'Độ mạnh 0-127' } - }, - required: ['pitch', 'start_time', 'duration'] - } - } - }, - required: ['item_id', 'notes'] - } + name: 'scan_track', description: 'Phân tích track: BPM, SR, kênh', parameters: { type: 'object', properties: { track_id: { type: 'string' }, set_tempo: { type: 'boolean' } } } }, { - name: 'export_audio', - description: 'Xuất (export/render) âm thanh ra file WAV/MP3/OGG và tải về. Lệnh DUY NHẤT cho thao tác xuất file - không cần gọi lệnh khác.', - parameters: { - type: 'object', - properties: { - track_id: { type: 'string', description: 'ID của track cần xuất. Nếu không có thì dùng track đang chọn.' }, - format: { type: 'string', enum: ['wav', 'mp3', 'ogg'], description: 'Định dạng file xuất' }, - sample_rate: { type: 'string', enum: ['22500', '44100'], description: 'Sample rate (Hz). Chỉ dùng cho WAV.' }, - bit_depth: { type: 'string', enum: ['8', '16', '24'], description: 'Bit depth. Chỉ dùng cho WAV.' }, - quality: { type: 'string', enum: ['44khz', 'lossless'], description: 'Chất lượng. Dùng cho MP3/OGG.' }, - channels: { type: 'string', enum: ['mono', 'stereo'], description: 'Số kênh (mono/stereo)' }, - start_time: { type: 'number', description: 'Vị trí bắt đầu xuất (giây).' }, - end_time: { type: 'number', description: 'Vị trí kết thúc xuất (giây).' }, - start_bar: { type: 'number', description: 'Bar bắt đầu (0 = bar đầu). Dùng thay cho start_time.' }, - length_bars: { type: 'number', description: 'Độ dài (bar). Dùng cùng start_bar thay cho end_time.' } - }, - required: ['format'] - } + name: 'fade_in', description: 'Fade-in clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' } } } + }, { + name: 'export_audio', description: 'Xuất file WAV/MP3/OGG và tải về', parameters: { type: 'object', properties: { track_id: { type: 'string' }, format: { type: 'string', enum: ['wav', 'mp3', 'ogg'] }, sample_rate: { type: 'string', enum: ['22500', '44100'] }, bit_depth: { type: 'string', enum: ['8', '16', '24'] }, quality: { type: 'string', enum: ['44khz', 'lossless'] }, channels: { type: 'string', enum: ['mono', 'stereo'] }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['format'] } + }, { + name: 'fade_out', description: 'Fade-out clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' } } } }]; function parseOrigin(urlStr) { @@ -343,7 +143,7 @@ const AIGateway = (function() { const contextStr = JSON.stringify(context, null, 2); const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n'); return [ - { role: 'system', content: `Bạn là trợ lý AI cho DAW. Dùng function calls để thực hiện yêu cầu. Các function có sẵn:\n${toolNames}\n\nHƯỚNG DẪN:\n- Để xuất file: gọi export_audio với format, sample_rate, bit_depth, channels, start_time, end_time.\n- Để chọn vùng: gọi set_selection với start_time/end_time hoặc start_bar/end_bar.\n- Bar 0 = bar đầu tiên.\n- Có thể gọi NHIỀU function cùng lúc, không cần chờ kết quả function trước.` }, + { role: 'system', content: `Bạn là trợ lý DAW. Dùng function calls. Bar 0 = bar đầu. Có thể gọi nhiều function cùng lúc.` }, { role: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` } ]; } @@ -391,6 +191,11 @@ const AIGateway = (function() { toolChoice: 'auto' }); + if (completion && completion.error) { + const errMsg = completion.error.message || completion.error.code || JSON.stringify(completion.error); + throw new Error(`AI Provider error: ${errMsg}`); + } + const functionCalls = extractFunctionCalls(completion); const textResponse = completion.choices && completion.choices[0] && completion.choices[0].message && completion.choices[0].message.content ? completion.choices[0].message.content diff --git a/app/static/js/services/dawCommandDispatcher.js b/app/static/js/services/dawCommandDispatcher.js index 7fb2bbd..b517b08 100644 --- a/app/static/js/services/dawCommandDispatcher.js +++ b/app/static/js/services/dawCommandDispatcher.js @@ -59,6 +59,8 @@ const DAWCommandDispatcher = (function() { register('PROCESS_AUDIO_DSP', (args) => api.processAudioDsp(args)); register('RENAME_TRACK', (args) => api.renameTrack(args)); register('SCAN_TRACK', (args) => api.scanTrack(args)); + register('FADE_IN', (args) => api.fadeIn(args)); + register('FADE_OUT', (args) => api.fadeOut(args)); register('CUT_AUDIO', (args) => api.cutAudio(args)); register('SET_SELECTION', (args) => api.setSelection(args)); register('EXPORT_AUDIO', (args) => api.exportAudio(args)); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 49fd79b..171b9dc 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ