diff --git a/app/api/v1/user_config.py b/app/api/v1/user_config.py index dbf42c4..8b8d636 100644 --- a/app/api/v1/user_config.py +++ b/app/api/v1/user_config.py @@ -1,12 +1,24 @@ -from fastapi import APIRouter, HTTPException, Depends +import json +from fastapi import APIRouter, HTTPException, Header, Depends from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, Dict, Any import time +from app.core.auth import decode_token router = APIRouter() +def _get_user_id(authorization): + if not authorization or not authorization.startswith("Bearer "): + return "anonymous" + token = authorization.split(" ")[1] + payload = decode_token(token) + if not payload: + return "anonymous" + return payload.get("user_id", "anonymous") + # In-memory / per-user AI provider configurations storage dictionary USER_AI_CONFIGS = {} +USER_PREFERENCES = {} class AIProviderSetting(BaseModel): id: str @@ -21,11 +33,25 @@ class AIProviderSetting(BaseModel): class SaveAIConfigRequest(BaseModel): providers: List[AIProviderSetting] +class SavePreferencesRequest(BaseModel): + preferences: Dict[str, Any] + +@router.get("/preferences") +async def get_user_preferences(authorization: Optional[str] = Header(None)): + uid = _get_user_id(authorization) + return {"success": True, "preferences": USER_PREFERENCES.get(uid, {})} + +@router.post("/preferences") +async def save_user_preferences(req: SavePreferencesRequest, authorization: Optional[str] = Header(None)): + uid = _get_user_id(authorization) + USER_PREFERENCES[uid] = req.preferences + return {"success": True, "message": "Đã lưu cấu hình người dùng."} + @router.get("/config/ai") -async def get_user_ai_config(): - """Fetch user's AI provider configurations.""" - if "default_user" not in USER_AI_CONFIGS: - USER_AI_CONFIGS["default_user"] = [ +async def get_user_ai_config(authorization: Optional[str] = Header(None)): + uid = _get_user_id(authorization) + if uid not in USER_AI_CONFIGS: + USER_AI_CONFIGS[uid] = [ { "id": "openai_default", "name": "OpenAI Official", @@ -69,13 +95,13 @@ async def get_user_ai_config(): ] return { "success": True, - "providers": USER_AI_CONFIGS["default_user"] + "providers": USER_AI_CONFIGS[uid] } @router.post("/config/ai") -async def save_user_ai_config(req: SaveAIConfigRequest): - """Save user's AI provider configurations.""" - USER_AI_CONFIGS["default_user"] = [p.dict() for p in req.providers] +async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)): + uid = _get_user_id(authorization) + USER_AI_CONFIGS[uid] = [p.dict() for p in req.providers] return { "success": True, "message": "Đã lưu cấu hình AI Providers thành công!" diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 8ab16c1..2ad3703 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3240,6 +3240,25 @@ const App = () => { setIsMandatoryLogin(false); setAuthModalOpen(false); loadPendingSfsProject(); + (async () => { + try { + const data = await window.SonicAPI.getPreferences(); + if (data && data.preferences) { + const p = data.preferences; + if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); + if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); + if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); + if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); + if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); + if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); + if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); + if (p.panelPositions) setPanelPositions(p.panelPositions); + if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); + if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); + if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); + } + } catch (e) { /* preferences not available */ } + })(); } }; const handleLogout = () => { @@ -7333,6 +7352,67 @@ const App = () => { setSelectionEnd(end); return { success: true, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)), length: parseFloat((end - start).toFixed(3)) }; }, + cutAudio: (args) => { + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { success: false, error: 'Track not found' }; + if (!track.buffer) return { success: false, error: 'Track has no audio buffer' }; + const barDur = 60 / parseInt(bpm || 120) * 4; + let rawStart, rawEnd; + if (args.start_time !== undefined) rawStart = args.start_time; + else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur; + else if (selectionStart !== null) rawStart = selectionStart; + else return { success: false, error: 'No selection or start position provided. Use set_selection first or provide start_time/start_bar.' }; + if (args.end_time !== undefined) rawEnd = args.end_time; + else if (args.length_bars !== undefined) rawEnd = rawStart + args.length_bars * barDur; + else if (selectionEnd !== null && selectionEnd > rawStart) rawEnd = selectionEnd; + else return { success: false, error: 'No end position provided. Use set_selection first or provide end_time/length_bars.' }; + const buffer = track.buffer; + const ctx = getAudioContext(); + const snap = args.snap_silence !== false; + const loopStart = snap ? findZeroCrossing(buffer, rawStart) : rawStart; + const loopEnd = snap ? findZeroCrossing(buffer, rawEnd) : rawEnd; + const sampleRate = buffer.sampleRate; + const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(loopStart * sampleRate))); + const endSample = Math.max(0, Math.min(buffer.length, Math.floor(loopEnd * sampleRate))); + const sliceLength = endSample - startSample; + if (sliceLength <= 100) return { success: false, error: 'Selection too short or invalid' }; + const numChannels = buffer.numberOfChannels || 1; + const slicedBuffer = ctx.createBuffer(numChannels, sliceLength, sampleRate); + for (let c = 0; c < numChannels; c++) { + const src = buffer.getChannelData(c); + const dst = slicedBuffer.getChannelData(c); + dst.set(src.subarray(startSample, endSample)); + } + const newId = 'track_cut_' + Date.now(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const color = colors[tracks.length % colors.length]; + const cutName = args.new_track_name || `Cut_${track.name}`; + const newTrack = { + id: newId, name: cutName, buffer: slicedBuffer, + startTime: 0, height: 128, volumeDb: 0, pan: 0, + muted: false, solo: false, color, markers: [], + clips: [{ id: 'clip_' + newId, buffer: slicedBuffer, startTime: 0, name: cutName }], + serverFileId: null + }; + setTracks(prev => { + const idx = prev.findIndex(t => t.id === tid); + const updated = [...prev]; + updated.splice(idx + 1, 0, newTrack); + return updated; + }); + setSelectedTrackId(newId); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(0); + setSelectionEnd(slicedBuffer.duration); + setTimeout(() => lucide.createIcons(), 200); + return { + success: true, trackId: newId, trackName: cutName, + cutStart: parseFloat(loopStart.toFixed(3)), cutEnd: parseFloat(loopEnd.toFixed(3)), + duration: parseFloat(slicedBuffer.duration.toFixed(3)) + }; + }, scanTrack: (args) => { const tid = args.track_id || selectedTrackId; const track = tracks.find(t => t.id === tid); @@ -7411,6 +7491,23 @@ const App = () => { localStorage.setItem('ai_api_key', aiConfig.apiKey); localStorage.setItem('ai_model', aiConfig.model); }, [aiConfig]); + + // ── Auto-save user preferences (panel state, provider) ── + const prefsRef = useRef({}); + prefsRef.current = { + showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, + showMediaExplorer, showFxRack, showMidiEvents, + panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId + }; + useEffect(() => { + if (!currentUser) return; + const timer = setTimeout(async () => { + try { await window.SonicAPI.savePreferences(prefsRef.current); } catch (e) {} + }, 2000); + return () => clearTimeout(timer); + }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, + showMediaExplorer, showFxRack, showMidiEvents, panelPositions, + rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]); return /*#__PURE__*/React.createElement("div", { className: "h-full w-full flex flex-col bg-[#1e1e1e]" }, /*#__PURE__*/React.createElement("header", { @@ -8296,7 +8393,7 @@ 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.5 mt-1" + 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" }, /*#__PURE__*/React.createElement("span", { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index fba9770..68d55f8 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -3240,6 +3240,25 @@ const App = () => { setIsMandatoryLogin(false); setAuthModalOpen(false); loadPendingSfsProject(); + (async () => { + try { + const data = await window.SonicAPI.getPreferences(); + if (data && data.preferences) { + const p = data.preferences; + if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); + if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); + if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); + if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); + if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); + if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); + if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); + if (p.panelPositions) setPanelPositions(p.panelPositions); + if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); + if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); + if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); + } + } catch (e) {/* preferences not available */} + })(); } }; const handleLogout = () => { @@ -7460,6 +7479,92 @@ const App = () => { length: parseFloat((end - start).toFixed(3)) }; }, + cutAudio: args => { + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { + success: false, + error: 'Track not found' + }; + if (!track.buffer) return { + success: false, + error: 'Track has no audio buffer' + }; + const barDur = 60 / parseInt(bpm || 120) * 4; + let rawStart, rawEnd; + if (args.start_time !== undefined) rawStart = args.start_time;else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur;else if (selectionStart !== null) rawStart = selectionStart;else return { + success: false, + error: 'No selection or start position provided. Use set_selection first or provide start_time/start_bar.' + }; + if (args.end_time !== undefined) rawEnd = args.end_time;else if (args.length_bars !== undefined) rawEnd = rawStart + args.length_bars * barDur;else if (selectionEnd !== null && selectionEnd > rawStart) rawEnd = selectionEnd;else return { + success: false, + error: 'No end position provided. Use set_selection first or provide end_time/length_bars.' + }; + const buffer = track.buffer; + const ctx = getAudioContext(); + const snap = args.snap_silence !== false; + const loopStart = snap ? findZeroCrossing(buffer, rawStart) : rawStart; + const loopEnd = snap ? findZeroCrossing(buffer, rawEnd) : rawEnd; + const sampleRate = buffer.sampleRate; + const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(loopStart * sampleRate))); + const endSample = Math.max(0, Math.min(buffer.length, Math.floor(loopEnd * sampleRate))); + const sliceLength = endSample - startSample; + if (sliceLength <= 100) return { + success: false, + error: 'Selection too short or invalid' + }; + const numChannels = buffer.numberOfChannels || 1; + const slicedBuffer = ctx.createBuffer(numChannels, sliceLength, sampleRate); + for (let c = 0; c < numChannels; c++) { + const src = buffer.getChannelData(c); + const dst = slicedBuffer.getChannelData(c); + dst.set(src.subarray(startSample, endSample)); + } + const newId = 'track_cut_' + Date.now(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const color = colors[tracks.length % colors.length]; + const cutName = args.new_track_name || `Cut_${track.name}`; + const newTrack = { + id: newId, + name: cutName, + buffer: slicedBuffer, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color, + markers: [], + clips: [{ + id: 'clip_' + newId, + buffer: slicedBuffer, + startTime: 0, + name: cutName + }], + serverFileId: null + }; + setTracks(prev => { + const idx = prev.findIndex(t => t.id === tid); + const updated = [...prev]; + updated.splice(idx + 1, 0, newTrack); + return updated; + }); + setSelectedTrackId(newId); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(0); + setSelectionEnd(slicedBuffer.duration); + setTimeout(() => lucide.createIcons(), 200); + return { + success: true, + trackId: newId, + trackName: cutName, + cutStart: parseFloat(loopStart.toFixed(3)), + cutEnd: parseFloat(loopEnd.toFixed(3)), + duration: parseFloat(slicedBuffer.duration.toFixed(3)) + }; + }, scanTrack: args => { const tid = args.track_id || selectedTrackId; const track = tracks.find(t => t.id === tid); @@ -7565,6 +7670,31 @@ const App = () => { localStorage.setItem('ai_api_key', aiConfig.apiKey); localStorage.setItem('ai_model', aiConfig.model); }, [aiConfig]); + + // ── Auto-save user preferences (panel state, provider) ── + const prefsRef = useRef({}); + prefsRef.current = { + showAIPanel, + showExportPanel, + showSelectionPanel, + showPythonToolsPanel, + showMediaExplorer, + showFxRack, + showMidiEvents, + panelPositions, + rightSidebarWidth, + mediaExplorerHeight, + selectedProviderId + }; + useEffect(() => { + if (!currentUser) return; + const timer = setTimeout(async () => { + try { + await window.SonicAPI.savePreferences(prefsRef.current); + } catch (e) {} + }, 2000); + return () => clearTimeout(timer); + }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, showMediaExplorer, showFxRack, showMidiEvents, panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]); return /*#__PURE__*/React.createElement("div", { className: "h-full w-full flex flex-col bg-[#1e1e1e]" }, /*#__PURE__*/React.createElement("header", { @@ -8444,7 +8574,7 @@ 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.5 mt-1" + 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" }, /*#__PURE__*/React.createElement("span", { diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index d007cfd..043bf1d 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -166,6 +166,21 @@ const AIGateway = (function() { length_bars: { type: 'number', description: 'Độ dài vùng chọn (bar). Dùng cùng start_bar thay cho end_bar.' } } } + }, { + name: 'cut_audio', + description: 'Cắt đoạn audio đang được chọn từ track nguồn, snap zero-crossing, tạo track mới chứa đoạn cắt', + 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 selection 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.' }, + length_bars: { type: 'number', description: 'Độ dài cắt (bar). Dùng cùng start_bar.' }, + snap_silence: { type: 'boolean', description: 'Tự động snap vào điểm silence/gần silence 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: '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', diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 0779b3e..72122b0 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -52,6 +52,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin; runPythonTool: (toolType, trackId, fileId, timePos = 0.0, freq = 440.0, duration = 2.0, waveType = "sine") => apiRequest('/api/v1/audio/python-tool', { method: 'POST', body: JSON.stringify({ tool_type: toolType, track_id: trackId, file_id: fileId, time_pos: timePos, freq: freq, duration: duration, wave_type: waveType }) }), getAIConfigs: () => apiRequest('/api/v1/user/config/ai', { method: 'GET' }), - saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }) + saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }), + + getPreferences: () => apiRequest('/api/v1/user/preferences', { method: 'GET' }), + savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) }) }; })(); diff --git a/app/static/js/services/dawCommandDispatcher.js b/app/static/js/services/dawCommandDispatcher.js index 719d2f4..e32671f 100644 --- a/app/static/js/services/dawCommandDispatcher.js +++ b/app/static/js/services/dawCommandDispatcher.js @@ -59,6 +59,7 @@ 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('CUT_AUDIO', (args) => api.cutAudio(args)); register('SET_SELECTION', (args) => api.setSelection(args)); register('SET_BPM', (args) => api.setBpm(args)); register('SET_PLAYHEAD', (args) => api.setPlayhead(args)); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 063dd24..bf8521e 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ