From 8a85dd2dfc960a2c6f41f74dea170ef708047eb8 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 22 Jul 2026 15:02:43 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20s=E1=BB=ADa=20copilot=20kh=C3=B4ng=20g?= =?UTF-8?q?=E1=BB=ADi=20AI=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/static/js/app.jsx | 366 +++++++++----- app/static/js/app.precompiled.js | 453 +++++++++++++----- app/static/js/services/aiGateway.js | 21 +- .../js/services/dawCommandDispatcher.js | 1 + app/storage/sonicforge.db | Bin 45056 -> 45056 bytes 5 files changed, 620 insertions(+), 221 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index a88cb92..0f16ecb 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3094,7 +3094,10 @@ const App = () => { const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', bitDepth: '16', - format: 'wav' + format: 'wav', + source: 'project', + quality: '44khz', + channels: 'stereo' }); const [serverStatus, setServerStatus] = useState('checking...'); const [menuOpen, setMenuOpen] = useState(null); @@ -3636,7 +3639,10 @@ const App = () => { copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipboardRef.current = { buffer: copyBuffer, - name: 'Subtab Clip' + name: 'Subtab Clip', + sampleRate: sr, + channels: 1, + speed: 1.0 }; showToast('Đã Copy vùng chọn.', 'success'); }; @@ -4581,14 +4587,20 @@ const App = () => { const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + const numCh = track.buffer.numberOfChannels || 1; + const clipBuffer = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuffer.copyToChannel(track.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } clipboardRef.current = { buffer: clipBuffer, name: track.name, volumeDb: track.volumeDb, pan: track.pan, - color: track.color + color: track.color, + sampleRate: clipBuffer.sampleRate, + channels: numCh, + speed: track.speed || 1.0 }; closeContextMenu(); showToast('Đã sao chép vùng chọn.', 'info'); @@ -4602,7 +4614,10 @@ const App = () => { name: track.name, volumeDb: track.volumeDb, pan: track.pan, - color: track.color + color: track.color, + sampleRate: track.buffer.sampleRate, + channels: track.buffer.numberOfChannels, + speed: track.speed || 1.0 }; closeContextMenu(); showToast('Đã sao chép toàn bộ track.', 'info'); @@ -4625,20 +4640,30 @@ const App = () => { const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + const numCh = t.buffer.numberOfChannels || 1; + const clipBuffer = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuffer.copyToChannel(t.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, - color: t.color + pan: t.pan, + color: t.color, + sampleRate: sr, + channels: numCh, + speed: t.speed || 1.0 }; const newLen = data.length - len; - const newBuffer = ctx.createBuffer(1, newLen, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + const newBuffer = ctx.createBuffer(numCh, newLen, sr); + for (let ch = 0; ch < numCh; ch++) { + const src = t.buffer.getChannelData(ch); + const dst = newBuffer.getChannelData(ch); + let idx = 0; + for (let i = 0; i < startSample; i++) dst[idx++] = src[i]; + for (let i = endSample; i < src.length; i++) dst[idx++] = src[i]; + } setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { ...tr, buffer: newBuffer @@ -4663,7 +4688,10 @@ const App = () => { name, volumeDb, pan, - color + color, + sampleRate, + channels, + speed } = clipboardRef.current; const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); @@ -4671,7 +4699,11 @@ const App = () => { id: nextClipId(), startTime: pasteTime, buffer: clipBuffer, - name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)' + name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)', + ...(volumeDb !== undefined ? { volumeDb } : {}), + ...(pan !== undefined ? { pan } : {}), + ...(color ? { color } : {}), + ...(speed !== undefined ? { speed } : {}) }; if (targetTrack) { setTracks(p => p.map(t => { @@ -4851,12 +4883,16 @@ const App = () => { } } // No selection: copy entire track - clipboardRef.current = { - buffer: t.buffer, - name: t.name, - volumeDb: t.volumeDb, - color: t.color - }; + clipboardRef.current = { + buffer: clipBuffer, + name: t.name, + volumeDb: t.volumeDb, + pan: t.pan, + color: t.color, + sampleRate: clipBuffer.sampleRate, + channels: clipBuffer.numberOfChannels, + speed: t.speed || 1.0 + }; showToast('Copied track to clipboard.', 'info'); }; const handleCutTrack = () => { @@ -5226,16 +5262,15 @@ const App = () => { // If selection cleared by user, play linearly (don't loop) if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { if (selRight > selLeft && updatedTime >= selRight) { - if (selectionMode === 'local') { - // Local Solo Loop: only restart the selected track + if (soloedTrackId !== null || selectionMode === 'local') { stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; - startLocalTrackPlayback(localSelectionTrackId, selLeft); + const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId; + startLocalTrackPlayback(soloTid, selLeft); setCurrentTime(selLeft); setIsPlaying(true); } else { - // Global Master Loop: restart all tracks stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; @@ -5615,11 +5650,18 @@ const App = () => { if (!clip) return; const beforeSnap = captureTrackSnapshot(trackId); if (isDuplicate) { + const cloneId = nextClipId(); + const clone = { ...clip, id: cloneId, startTime: clip.startTime || 0, name: clip.name + ' (Copy)' }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const newClips = [...existingClips, clone]; + return { ...t, clips: newClips, buffer: newClips[0].buffer, startTime: newClips[0].startTime, name: newClips[0].name }; + } + return t; + })); setDraggedClip({ - trackId, clipId: clip.id, clickOffset, - buffer: clip.buffer, name: clip.name, beforeSnap, - isDuplicate: true, - origTrackId: trackId, origClipId: clip.id + trackId, clipId: cloneId, clickOffset, buffer: clip.buffer, + name: clip.name + ' (Copy)', beforeSnap, isDuplicate: false }); return; } @@ -5791,22 +5833,10 @@ const App = () => { const handleMouseUp = () => { const drag = draggedClipRef.current; if (!drag) return; - if (drag.isDuplicate) { - const cloneId = nextClipId(); - const clone = { id: cloneId, buffer: drag.buffer, startTime: 0, name: drag.name.replace(/\.\w+$/, '') + ' (Copy)' }; - setTracks(prev => prev.map(t => { - if (t.id === drag.trackId) { - const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []); - return { ...t, clips: [...clips, clone], buffer: clips.length > 0 ? clips[0].buffer : drag.buffer }; - } - return t; - })); - setDraggedClip({ ...drag, clipId: cloneId, isDuplicate: false }); - } const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction(drag.isDuplicate ? 'DUPLICATE_CLIP' : 'MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); setDraggedClip(null); - showToast(drag.isDuplicate ? 'Đã sao chép clip.' : 'Đã di chuyển clip.', 'success'); + showToast('Đã di chuyển clip.', 'success'); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); @@ -6199,21 +6229,53 @@ const App = () => { // ── Server-side Export ── const triggerWavExport = async () => { - const activeTracks = tracks.filter(t => t.buffer && !t.muted); - if (activeTracks.length === 0) { + let exportTracks; + let clipStart = 0; + let clipEnd = 0; + const src = exportSettings.source; + if (src === 'active_clip' || src === 'clip_selection') { + const selTrack = selectedTrackId ? tracks.find(t => t.id === selectedTrackId) : null; + if (!selTrack || !selTrack.buffer) { showToast("Không có clip nào được chọn.", "warning"); return; } + let rangeStart = selectionStart; + let rangeEnd = selectionEnd; + if (rangeStart === null || rangeEnd === null || rangeEnd <= rangeStart) { + rangeStart = 0; + rangeEnd = selTrack.buffer.duration; + } + const ctx = getAudioContext(); + const numCh = selTrack.buffer.numberOfChannels || 1; + const sr = selTrack.buffer.sampleRate; + const startSample = Math.max(0, Math.floor(rangeStart * sr)); + const endSample = Math.min(selTrack.buffer.length, Math.floor(rangeEnd * sr)); + const len = endSample - startSample; + if (len <= 100) { showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.", "warning"); return; } + const clipBuf = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuf.copyToChannel(selTrack.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } + exportTracks = [{ ...selTrack, buffer: clipBuf, startTime: 0, clips: [{ id: 'export_clip', buffer: clipBuf, startTime: 0, name: selTrack.name }] }]; + } else if (src === 'track_mix') { + const sel = tracks.filter(t => t.buffer && !t.muted); + const selTrk = selectedTrackId ? sel.filter(t => t.id === selectedTrackId) : sel; + if (selTrk.length === 0) { showToast("Track được chọn không có dữ liệu.", "warning"); return; } + exportTracks = selTrk; + } else { + exportTracks = tracks.filter(t => t.buffer && !t.muted); + } + if (exportTracks.length === 0) { showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); return; } // Check if all active tracks have server file IDs - const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]); + const allOnServer = exportTracks.every(t => serverFileIdMap[t.id]); if (allOnServer && serverStatus === 'connected') { // Use server-side export setIsExporting(true); showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); try { const sessionId = `session_${Date.now()}`; - const tracksMeta = activeTracks.map(t => ({ + const tracksMeta = exportTracks.map(t => ({ track_id: t.id, file_id: serverFileIdMap[t.id], volume_db: t.volumeDb, @@ -6238,7 +6300,8 @@ const App = () => { export_settings: { sample_rate: parseInt(exportSettings.sampleRate), bit_depth: parseInt(exportSettings.bitDepth), - format: exportSettings.format + format: exportSettings.format, + channels: exportSettings.channels }, tracks: tracksMeta }) @@ -6266,13 +6329,13 @@ const App = () => { } catch (err) { showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); // Fall back to client-side export - clientSideExport(activeTracks); + clientSideExport(exportTracks); } finally { setIsExporting(false); } } else { // Client-side export (existing working code) - clientSideExport(activeTracks); + clientSideExport(exportTracks); } }; const handleSaveCloud = async () => { @@ -6382,7 +6445,8 @@ const App = () => { if (clips.length === 0) return 0; return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); })); - const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); + const outChannels = exportSettings.channels === 'mono' ? 1 : 2; + const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); activeTracks.forEach(t => { const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ id: 'default', @@ -6410,11 +6474,11 @@ const App = () => { }); }); const renderedBuffer = await offlineCtx.startRendering(); - const monoData = renderedBuffer.getChannelData(0); - const bufferLength = monoData.length; + const numExportCh = renderedBuffer.numberOfChannels; + const exportLength = renderedBuffer.length; const bytesPerSample = bitDepth / 8; const headerSize = 44; - const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh; const fileBuffer = new ArrayBuffer(fileSizeBytes); const view = new DataView(fileBuffer); const writeString = (offset, string) => { @@ -6428,27 +6492,30 @@ const App = () => { writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); - view.setUint16(22, 1, true); + view.setUint16(22, numExportCh, true); view.setUint32(24, targetRate, true); - view.setUint32(28, targetRate * bytesPerSample, true); + view.setUint32(28, targetRate * bytesPerSample * numExportCh, true); view.setUint16(32, bytesPerSample, true); view.setUint16(34, bitDepth, true); writeString(36, 'data'); - view.setUint32(40, bufferLength * bytesPerSample, true); + view.setUint32(40, exportLength * bytesPerSample * numExportCh, true); let offset = 44; - for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - if (bitDepth === 8) { - view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); - } else if (bitDepth === 16) { - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - } else if (bitDepth === 24) { - const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); - view.setUint8(offset, val24 & 0xFF); - view.setUint8(offset + 1, val24 >> 8 & 0xFF); - view.setUint8(offset + 2, val24 >> 16 & 0xFF); + for (let i = 0; i < exportLength; i++) { + for (let ch = 0; ch < numExportCh; ch++) { + const chData = renderedBuffer.getChannelData(ch); + const sample = Math.max(-1, Math.min(1, chData[i])); + if (bitDepth === 8) { + view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); + } else if (bitDepth === 16) { + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + } else if (bitDepth === 24) { + const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); + view.setUint8(offset, val24 & 0xFF); + view.setUint8(offset + 1, val24 >> 8 & 0xFF); + view.setUint8(offset + 2, val24 >> 16 & 0xFF); + } + offset += bytesPerSample; } - offset += bytesPerSample; } const blob = new Blob([view], { type: 'audio/wav' @@ -7047,19 +7114,30 @@ const App = () => { setAiProcessing(true); setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]); try { - const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); - const provider = selectedProvider || aiConfig; + 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} | URL: ${baseUrl.slice(0, 40)}`, time: Date.now() }]); const dawContext = window.AIGateway.buildAIPromptContext({ tracks, bpm, selectedTrackId, currentTime, selLeft, selRight }); const result = await window.AIGateway.executeAIPrompt({ - prompt, + prompt: prompt, provider: provider.name || 'default', - model, - apiKey, + model: model, + apiKey: apiKey, baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), dawContext, tools: window.AIGateway.DEFAULT_TOOLS @@ -7076,8 +7154,9 @@ const App = () => { const cmdName = fc.name.toUpperCase(); if (window.DAWCommandDispatcher) { try { - const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); - setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult.error || 'unknown')}`, time: Date.now() }]); + let cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); + if (cmdResult && typeof cmdResult.then === 'function') cmdResult = await cmdResult; + setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult && cmdResult.success ? 'thành công' : 'thất bại: ' + ((cmdResult && cmdResult.error) || 'unknown')}`, time: Date.now() }]); } catch (cmdErr) { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]); } @@ -7087,7 +7166,9 @@ const App = () => { } } if (!hasText && !hasCalls) { - setAiActionLog(prev => [...prev, { type: 'error', text: ` AI không trả về lệnh hoặc text. Kiểm tra provider/model có hỗ trợ function calling.`, time: Date.now() }]); + const rawKeys = result.raw ? Object.keys(result.raw).join(', ') : 'null'; + const errDetail = result.raw && result.raw.error ? ` (${result.raw.error.message || result.raw.error})` : ''; + setAiActionLog(prev => [...prev, { type: 'error', text: ` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`, time: Date.now() }]); } if (prompt) { promptHistRef.current = [...promptHistRef.current.slice(-49), prompt]; @@ -7524,6 +7605,62 @@ const App = () => { handlePlayheadSet(time); return { success: true, time: parseFloat(time.toFixed(3)) }; }, + exportAudio: async (args) => { + const tid = args.track_id || selectedTrackId; + const track = tid && tracks.find(t => t.id === tid); + if (!track || !track.buffer) return { success: false, error: 'No track or audio data' }; + const barDur = 60 / parseInt(bpm || 120) * 4; + const sel = selectionRef.current; + 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 (sel.start !== null) rawStart = sel.start; + else rawStart = 0; + if (args.end_time !== undefined) rawEnd = args.end_time; + else if (args.length_bars !== undefined) rawEnd = (rawStart || 0) + args.length_bars * barDur; + else if (args.end_bar !== undefined) rawEnd = args.end_bar * barDur; + else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end; + else rawEnd = track.buffer.duration; + const ctx = getAudioContext(); + const sr = parseInt(args.sample_rate || '44100'); + const numCh = args.channels === 'mono' ? 1 : (track.buffer.numberOfChannels || 2); + const bd = parseInt(args.bit_depth || '16'); + const fmt = args.format || 'wav'; + const offlineCtx = new OfflineAudioContext(numCh, Math.ceil(sr * Math.min(rawEnd - rawStart, track.buffer.duration)), sr); + const source = offlineCtx.createBufferSource(); + source.buffer = track.buffer; + source.start(0, rawStart, rawEnd - rawStart); + source.connect(offlineCtx.destination); + const renderedBuffer = await offlineCtx.startRendering(); + const len = renderedBuffer.length; + const bps = bd / 8; + const hdrSz = 44; + const fileBuf = new ArrayBuffer(hdrSz + len * bps * numCh); + const vw = new DataView(fileBuf); + const ws = (off, s) => { for (let i = 0; i < s.length; i++) vw.setUint8(off + i, s.charCodeAt(i)); }; + ws(0, 'RIFF'); vw.setUint32(4, fileBuf.byteLength - 8, true); ws(8, 'WAVE'); + ws(12, 'fmt '); vw.setUint32(16, 16, true); vw.setUint16(20, 1, true); vw.setUint16(22, numCh, true); + vw.setUint32(24, sr, true); vw.setUint32(28, sr * bps * numCh, true); vw.setUint16(32, bps * numCh, true); + vw.setUint16(34, bd, true); ws(36, 'data'); vw.setUint32(40, len * bps * numCh, true); + let ofs = 44; + for (let i = 0; i < len; i++) { + for (let ch = 0; ch < numCh; ch++) { + const smp = Math.max(-1, Math.min(1, renderedBuffer.getChannelData(ch)[i])); + if (bd === 8) vw.setUint8(ofs, Math.floor((smp + 1) * 127.5)); + else if (bd === 16) vw.setInt16(ofs, Math.floor(smp < 0 ? smp * 0x8000 : smp * 0x7FFF), true); + else { const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); vw.setUint8(ofs, v24 & 0xFF); vw.setUint8(ofs+1, v24 >> 8 & 0xFF); vw.setUint8(ofs+2, v24 >> 16 & 0xFF); } + ofs += bps; + } + } + const blob = new Blob([fileBuf], { type: 'audio/' + fmt }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `export_${Date.now()}.${fmt}`; + a.click(); + URL.revokeObjectURL(url); + return { success: true, trackId: tid, range: parseFloat((rawEnd - rawStart).toFixed(3)) + 's', format: fmt, channels: numCh === 1 ? 'mono' : 'stereo' }; + }, selectItem: (args) => { if (args.select_all) { setSelectedTrackId(null); @@ -8384,45 +8521,50 @@ const App = () => { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1" + className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "SR"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.sampleRate, - onChange: e => setExportSettings(p => ({ - ...p, - sampleRate: e.target.value - })), + }, "Ngu\u1ed3n"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.source, + onChange: e => setExportSettings(p => ({ ...p, source: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "44100" - }, "44.1k"), /*#__PURE__*/React.createElement("option", { - value: "48000" - }, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + }, /*#__PURE__*/React.createElement("option", { value: "project" }, "Project (Mix)"), /*#__PURE__*/React.createElement("option", { value: "track_mix" }, "Track Selection"), /*#__PURE__*/React.createElement("option", { value: "active_clip" }, "Active Clip"), /*#__PURE__*/React.createElement("option", { value: "clip_selection" }, "Clip Selection"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "\u0110\u1ecbnh d\u1ea1ng"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.format, + onChange: e => setExportSettings(p => ({ ...p, format: e.target.value, sampleRate: e.target.value === 'wav' ? '44100' : e.target.value === 'mp3' ? '44100' : '44100', bitDepth: e.target.value === 'wav' ? '16' : '16', quality: '44khz' })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { value: "wav" }, "WAV"), /*#__PURE__*/React.createElement("option", { value: "mp3" }, "MP3"), /*#__PURE__*/React.createElement("option", { value: "ogg" }, "OGG")))), exportSettings.format === 'wav' ? /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "SR (Hz)"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.sampleRate, + onChange: e => setExportSettings(p => ({ ...p, sampleRate: e.target.value })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { value: "22500" }, "22500"), /*#__PURE__*/React.createElement("option", { value: "44100" }, "44100"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Bit"), /*#__PURE__*/React.createElement("select", { value: exportSettings.bitDepth, - onChange: e => setExportSettings(p => ({ - ...p, - bitDepth: e.target.value - })), + onChange: e => setExportSettings(p => ({ ...p, bitDepth: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "16" - }, "16"), /*#__PURE__*/React.createElement("option", { - value: "24" - }, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + }, /*#__PURE__*/React.createElement("option", { value: "8" }, "8"), /*#__PURE__*/React.createElement("option", { value: "16" }, "16"), /*#__PURE__*/React.createElement("option", { value: "24" }, "24")))) : /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Fmt"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.format, - onChange: e => setExportSettings(p => ({ - ...p, - format: e.target.value - })), + }, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.quality, + onChange: e => setExportSettings(p => ({ ...p, quality: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" - }, /*#__PURE__*/React.createElement("option", { - value: "wav" - }, "WAV")))), /*#__PURE__*/React.createElement("button", { + }, /*#__PURE__*/React.createElement("option", { value: "44khz" }, "44kHz"), /*#__PURE__*/React.createElement("option", { value: "lossless" }, "Lossless"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Kênh"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.channels, + onChange: e => setExportSettings(p => ({ ...p, channels: e.target.value })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { value: "mono" }, "Mono"), /*#__PURE__*/React.createElement("option", { value: "stereo" }, "Stereo"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("button", { onClick: triggerWavExport, disabled: isExporting, className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1" @@ -8544,9 +8686,9 @@ const App = () => { }, "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.5 mt-1 flex-1 min-h-0 flex flex-col" + 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-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between shrink-0" + 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", { @@ -8565,9 +8707,9 @@ const App = () => { setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]); } }, - className: "text-xs text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0.5" + 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 p-1 select-text" + 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", { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 0eae3ea..35e2715 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -3137,7 +3137,10 @@ const App = () => { const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', bitDepth: '16', - format: 'wav' + format: 'wav', + source: 'project', + quality: '44khz', + channels: 'stereo' }); const [serverStatus, setServerStatus] = useState('checking...'); const [menuOpen, setMenuOpen] = useState(null); @@ -3685,7 +3688,10 @@ const App = () => { copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipboardRef.current = { buffer: copyBuffer, - name: 'Subtab Clip' + name: 'Subtab Clip', + sampleRate: sr, + channels: 1, + speed: 1.0 }; showToast('Đã Copy vùng chọn.', 'success'); }; @@ -4630,14 +4636,20 @@ const App = () => { const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + const numCh = track.buffer.numberOfChannels || 1; + const clipBuffer = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuffer.copyToChannel(track.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } clipboardRef.current = { buffer: clipBuffer, name: track.name, volumeDb: track.volumeDb, pan: track.pan, - color: track.color + color: track.color, + sampleRate: clipBuffer.sampleRate, + channels: numCh, + speed: track.speed || 1.0 }; closeContextMenu(); showToast('Đã sao chép vùng chọn.', 'info'); @@ -4651,7 +4663,10 @@ const App = () => { name: track.name, volumeDb: track.volumeDb, pan: track.pan, - color: track.color + color: track.color, + sampleRate: track.buffer.sampleRate, + channels: track.buffer.numberOfChannels, + speed: track.speed || 1.0 }; closeContextMenu(); showToast('Đã sao chép toàn bộ track.', 'info'); @@ -4674,20 +4689,30 @@ const App = () => { const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + const numCh = t.buffer.numberOfChannels || 1; + const clipBuffer = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuffer.copyToChannel(t.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, - color: t.color + pan: t.pan, + color: t.color, + sampleRate: sr, + channels: numCh, + speed: t.speed || 1.0 }; const newLen = data.length - len; - const newBuffer = ctx.createBuffer(1, newLen, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + const newBuffer = ctx.createBuffer(numCh, newLen, sr); + for (let ch = 0; ch < numCh; ch++) { + const src = t.buffer.getChannelData(ch); + const dst = newBuffer.getChannelData(ch); + let idx = 0; + for (let i = 0; i < startSample; i++) dst[idx++] = src[i]; + for (let i = endSample; i < src.length; i++) dst[idx++] = src[i]; + } setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { ...tr, buffer: newBuffer @@ -4712,7 +4737,10 @@ const App = () => { name, volumeDb, pan, - color + color, + sampleRate, + channels, + speed } = clipboardRef.current; const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); @@ -4720,7 +4748,19 @@ const App = () => { id: nextClipId(), startTime: pasteTime, buffer: clipBuffer, - name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)' + name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)', + ...(volumeDb !== undefined ? { + volumeDb + } : {}), + ...(pan !== undefined ? { + pan + } : {}), + ...(color ? { + color + } : {}), + ...(speed !== undefined ? { + speed + } : {}) }; if (targetTrack) { setTracks(p => p.map(t => { @@ -4901,10 +4941,14 @@ const App = () => { } // No selection: copy entire track clipboardRef.current = { - buffer: t.buffer, + buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, - color: t.color + pan: t.pan, + color: t.color, + sampleRate: clipBuffer.sampleRate, + channels: clipBuffer.numberOfChannels, + speed: t.speed || 1.0 }; showToast('Copied track to clipboard.', 'info'); }; @@ -5275,16 +5319,15 @@ const App = () => { // If selection cleared by user, play linearly (don't loop) if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { if (selRight > selLeft && updatedTime >= selRight) { - if (selectionMode === 'local') { - // Local Solo Loop: only restart the selected track + if (soloedTrackId !== null || selectionMode === 'local') { stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; - startLocalTrackPlayback(localSelectionTrackId, selLeft); + const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId; + startLocalTrackPlayback(soloTid, selLeft); setCurrentTime(selLeft); setIsPlaying(true); } else { - // Global Master Loop: restart all tracks stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; @@ -5663,16 +5706,34 @@ const App = () => { if (!clip) return; const beforeSnap = captureTrackSnapshot(trackId); if (isDuplicate) { + const cloneId = nextClipId(); + const clone = { + ...clip, + id: cloneId, + startTime: clip.startTime || 0, + name: clip.name + ' (Copy)' + }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const newClips = [...existingClips, clone]; + return { + ...t, + clips: newClips, + buffer: newClips[0].buffer, + startTime: newClips[0].startTime, + name: newClips[0].name + }; + } + return t; + })); setDraggedClip({ trackId, - clipId: clip.id, + clipId: cloneId, clickOffset, buffer: clip.buffer, - name: clip.name, + name: clip.name + ' (Copy)', beforeSnap, - isDuplicate: true, - origTrackId: trackId, - origClipId: clip.id + isDuplicate: false }); return; } @@ -5851,40 +5912,10 @@ const App = () => { const handleMouseUp = () => { const drag = draggedClipRef.current; if (!drag) return; - if (drag.isDuplicate) { - const cloneId = nextClipId(); - const clone = { - id: cloneId, - buffer: drag.buffer, - startTime: 0, - name: drag.name.replace(/\.\w+$/, '') + ' (Copy)' - }; - setTracks(prev => prev.map(t => { - if (t.id === drag.trackId) { - const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []; - return { - ...t, - clips: [...clips, clone], - buffer: clips.length > 0 ? clips[0].buffer : drag.buffer - }; - } - return t; - })); - setDraggedClip({ - ...drag, - clipId: cloneId, - isDuplicate: false - }); - } const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction(drag.isDuplicate ? 'DUPLICATE_CLIP' : 'MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); setDraggedClip(null); - showToast(drag.isDuplicate ? 'Đã sao chép clip.' : 'Đã di chuyển clip.', 'success'); + showToast('Đã di chuyển clip.', 'success'); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); @@ -6283,21 +6314,72 @@ const App = () => { // ── Server-side Export ── const triggerWavExport = async () => { - const activeTracks = tracks.filter(t => t.buffer && !t.muted); - if (activeTracks.length === 0) { + let exportTracks; + let clipStart = 0; + let clipEnd = 0; + const src = exportSettings.source; + if (src === 'active_clip' || src === 'clip_selection') { + const selTrack = selectedTrackId ? tracks.find(t => t.id === selectedTrackId) : null; + if (!selTrack || !selTrack.buffer) { + showToast("Không có clip nào được chọn.", "warning"); + return; + } + let rangeStart = selectionStart; + let rangeEnd = selectionEnd; + if (rangeStart === null || rangeEnd === null || rangeEnd <= rangeStart) { + rangeStart = 0; + rangeEnd = selTrack.buffer.duration; + } + const ctx = getAudioContext(); + const numCh = selTrack.buffer.numberOfChannels || 1; + const sr = selTrack.buffer.sampleRate; + const startSample = Math.max(0, Math.floor(rangeStart * sr)); + const endSample = Math.min(selTrack.buffer.length, Math.floor(rangeEnd * sr)); + const len = endSample - startSample; + if (len <= 100) { + showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.", "warning"); + return; + } + const clipBuf = ctx.createBuffer(numCh, len, sr); + for (let ch = 0; ch < numCh; ch++) { + clipBuf.copyToChannel(selTrack.buffer.getChannelData(ch).subarray(startSample, endSample), ch); + } + exportTracks = [{ + ...selTrack, + buffer: clipBuf, + startTime: 0, + clips: [{ + id: 'export_clip', + buffer: clipBuf, + startTime: 0, + name: selTrack.name + }] + }]; + } else if (src === 'track_mix') { + const sel = tracks.filter(t => t.buffer && !t.muted); + const selTrk = selectedTrackId ? sel.filter(t => t.id === selectedTrackId) : sel; + if (selTrk.length === 0) { + showToast("Track được chọn không có dữ liệu.", "warning"); + return; + } + exportTracks = selTrk; + } else { + exportTracks = tracks.filter(t => t.buffer && !t.muted); + } + if (exportTracks.length === 0) { showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); return; } // Check if all active tracks have server file IDs - const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]); + const allOnServer = exportTracks.every(t => serverFileIdMap[t.id]); if (allOnServer && serverStatus === 'connected') { // Use server-side export setIsExporting(true); showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); try { const sessionId = `session_${Date.now()}`; - const tracksMeta = activeTracks.map(t => ({ + const tracksMeta = exportTracks.map(t => ({ track_id: t.id, file_id: serverFileIdMap[t.id], volume_db: t.volumeDb, @@ -6322,7 +6404,8 @@ const App = () => { export_settings: { sample_rate: parseInt(exportSettings.sampleRate), bit_depth: parseInt(exportSettings.bitDepth), - format: exportSettings.format + format: exportSettings.format, + channels: exportSettings.channels }, tracks: tracksMeta }) @@ -6350,13 +6433,13 @@ const App = () => { } catch (err) { showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); // Fall back to client-side export - clientSideExport(activeTracks); + clientSideExport(exportTracks); } finally { setIsExporting(false); } } else { // Client-side export (existing working code) - clientSideExport(activeTracks); + clientSideExport(exportTracks); } }; const handleSaveCloud = async () => { @@ -6466,7 +6549,8 @@ const App = () => { if (clips.length === 0) return 0; return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); })); - const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); + const outChannels = exportSettings.channels === 'mono' ? 1 : 2; + const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); activeTracks.forEach(t => { const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ id: 'default', @@ -6494,11 +6578,11 @@ const App = () => { }); }); const renderedBuffer = await offlineCtx.startRendering(); - const monoData = renderedBuffer.getChannelData(0); - const bufferLength = monoData.length; + const numExportCh = renderedBuffer.numberOfChannels; + const exportLength = renderedBuffer.length; const bytesPerSample = bitDepth / 8; const headerSize = 44; - const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh; const fileBuffer = new ArrayBuffer(fileSizeBytes); const view = new DataView(fileBuffer); const writeString = (offset, string) => { @@ -6512,27 +6596,30 @@ const App = () => { writeString(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); - view.setUint16(22, 1, true); + view.setUint16(22, numExportCh, true); view.setUint32(24, targetRate, true); - view.setUint32(28, targetRate * bytesPerSample, true); + view.setUint32(28, targetRate * bytesPerSample * numExportCh, true); view.setUint16(32, bytesPerSample, true); view.setUint16(34, bitDepth, true); writeString(36, 'data'); - view.setUint32(40, bufferLength * bytesPerSample, true); + view.setUint32(40, exportLength * bytesPerSample * numExportCh, true); let offset = 44; - for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - if (bitDepth === 8) { - view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); - } else if (bitDepth === 16) { - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - } else if (bitDepth === 24) { - const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); - view.setUint8(offset, val24 & 0xFF); - view.setUint8(offset + 1, val24 >> 8 & 0xFF); - view.setUint8(offset + 2, val24 >> 16 & 0xFF); + for (let i = 0; i < exportLength; i++) { + for (let ch = 0; ch < numExportCh; ch++) { + const chData = renderedBuffer.getChannelData(ch); + const sample = Math.max(-1, Math.min(1, chData[i])); + if (bitDepth === 8) { + view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); + } else if (bitDepth === 16) { + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + } else if (bitDepth === 24) { + const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); + view.setUint8(offset, val24 & 0xFF); + view.setUint8(offset + 1, val24 >> 8 & 0xFF); + view.setUint8(offset + 2, val24 >> 16 & 0xFF); + } + offset += bytesPerSample; } - offset += bytesPerSample; } const blob = new Blob([view], { type: 'audio/wav' @@ -7138,11 +7225,26 @@ const App = () => { time: Date.now() }]); try { - const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); - const provider = selectedProvider || aiConfig; + 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} | URL: ${baseUrl.slice(0, 40)}`, + time: Date.now() + }]); const dawContext = window.AIGateway.buildAIPromptContext({ tracks, bpm, @@ -7152,10 +7254,10 @@ const App = () => { selRight }); const result = await window.AIGateway.executeAIPrompt({ - prompt, + prompt: prompt, provider: provider.name || 'default', - model, - apiKey, + model: model, + apiKey: apiKey, baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), dawContext, tools: window.AIGateway.DEFAULT_TOOLS @@ -7184,10 +7286,11 @@ const App = () => { const cmdName = fc.name.toUpperCase(); if (window.DAWCommandDispatcher) { try { - const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); + let cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); + if (cmdResult && typeof cmdResult.then === 'function') cmdResult = await cmdResult; setAiActionLog(prev => [...prev, { type: 'status', - text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult.error || 'unknown')}`, + text: ` ✅ ${fc.name}: ${cmdResult && cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult && cmdResult.error || 'unknown')}`, time: Date.now() }]); } catch (cmdErr) { @@ -7207,9 +7310,11 @@ const App = () => { } } if (!hasText && !hasCalls) { + const rawKeys = result.raw ? Object.keys(result.raw).join(', ') : 'null'; + const errDetail = result.raw && result.raw.error ? ` (${result.raw.error.message || result.raw.error})` : ''; setAiActionLog(prev => [...prev, { type: 'error', - text: ` AI không trả về lệnh hoặc text. Kiểm tra provider/model có hỗ trợ function calling.`, + text: ` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`, time: Date.now() }]); } @@ -7779,6 +7884,80 @@ const App = () => { time: parseFloat(time.toFixed(3)) }; }, + exportAudio: async args => { + const tid = args.track_id || selectedTrackId; + const track = tid && tracks.find(t => t.id === tid); + if (!track || !track.buffer) return { + success: false, + error: 'No track or audio data' + }; + const barDur = 60 / parseInt(bpm || 120) * 4; + const sel = selectionRef.current; + 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 (sel.start !== null) rawStart = sel.start;else rawStart = 0; + if (args.end_time !== undefined) rawEnd = args.end_time;else if (args.length_bars !== undefined) rawEnd = (rawStart || 0) + args.length_bars * barDur;else if (args.end_bar !== undefined) rawEnd = args.end_bar * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else rawEnd = track.buffer.duration; + const ctx = getAudioContext(); + const sr = parseInt(args.sample_rate || '44100'); + const numCh = args.channels === 'mono' ? 1 : track.buffer.numberOfChannels || 2; + const bd = parseInt(args.bit_depth || '16'); + const fmt = args.format || 'wav'; + const offlineCtx = new OfflineAudioContext(numCh, Math.ceil(sr * Math.min(rawEnd - rawStart, track.buffer.duration)), sr); + const source = offlineCtx.createBufferSource(); + source.buffer = track.buffer; + source.start(0, rawStart, rawEnd - rawStart); + source.connect(offlineCtx.destination); + const renderedBuffer = await offlineCtx.startRendering(); + const len = renderedBuffer.length; + const bps = bd / 8; + const hdrSz = 44; + const fileBuf = new ArrayBuffer(hdrSz + len * bps * numCh); + const vw = new DataView(fileBuf); + const ws = (off, s) => { + for (let i = 0; i < s.length; i++) vw.setUint8(off + i, s.charCodeAt(i)); + }; + ws(0, 'RIFF'); + vw.setUint32(4, fileBuf.byteLength - 8, true); + ws(8, 'WAVE'); + ws(12, 'fmt '); + vw.setUint32(16, 16, true); + vw.setUint16(20, 1, true); + vw.setUint16(22, numCh, true); + vw.setUint32(24, sr, true); + vw.setUint32(28, sr * bps * numCh, true); + vw.setUint16(32, bps * numCh, true); + vw.setUint16(34, bd, true); + ws(36, 'data'); + vw.setUint32(40, len * bps * numCh, true); + let ofs = 44; + for (let i = 0; i < len; i++) { + for (let ch = 0; ch < numCh; ch++) { + const smp = Math.max(-1, Math.min(1, renderedBuffer.getChannelData(ch)[i])); + if (bd === 8) vw.setUint8(ofs, Math.floor((smp + 1) * 127.5));else if (bd === 16) vw.setInt16(ofs, Math.floor(smp < 0 ? smp * 0x8000 : smp * 0x7FFF), true);else { + const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); + vw.setUint8(ofs, v24 & 0xFF); + vw.setUint8(ofs + 1, v24 >> 8 & 0xFF); + vw.setUint8(ofs + 2, v24 >> 16 & 0xFF); + } + ofs += bps; + } + } + const blob = new Blob([fileBuf], { + type: 'audio/' + fmt + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `export_${Date.now()}.${fmt}`; + a.click(); + URL.revokeObjectURL(url); + return { + success: true, + trackId: tid, + range: parseFloat((rawEnd - rawStart).toFixed(3)) + 's', + format: fmt, + channels: numCh === 1 ? 'mono' : 'stereo' + }; + }, selectItem: args => { if (args.select_all) { setSelectedTrackId(null); @@ -8683,10 +8862,47 @@ const App = () => { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1" + className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "SR"), /*#__PURE__*/React.createElement("select", { + }, "Ngu\u1ed3n"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.source, + onChange: e => setExportSettings(p => ({ + ...p, + source: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "project" + }, "Project (Mix)"), /*#__PURE__*/React.createElement("option", { + value: "track_mix" + }, "Track Selection"), /*#__PURE__*/React.createElement("option", { + value: "active_clip" + }, "Active Clip"), /*#__PURE__*/React.createElement("option", { + value: "clip_selection" + }, "Clip Selection"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "\u0110\u1ecbnh d\u1ea1ng"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.format, + onChange: e => setExportSettings(p => ({ + ...p, + format: e.target.value, + sampleRate: e.target.value === 'wav' ? '44100' : e.target.value === 'mp3' ? '44100' : '44100', + bitDepth: e.target.value === 'wav' ? '16' : '16', + quality: '44khz' + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "wav" + }, "WAV"), /*#__PURE__*/React.createElement("option", { + value: "mp3" + }, "MP3"), /*#__PURE__*/React.createElement("option", { + value: "ogg" + }, "OGG")))), exportSettings.format === 'wav' ? /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "SR (Hz)"), /*#__PURE__*/React.createElement("select", { value: exportSettings.sampleRate, onChange: e => setExportSettings(p => ({ ...p, @@ -8694,10 +8910,10 @@ const App = () => { })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { + value: "22500" + }, "22500"), /*#__PURE__*/React.createElement("option", { value: "44100" - }, "44.1k"), /*#__PURE__*/React.createElement("option", { - value: "48000" - }, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + }, "44100"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Bit"), /*#__PURE__*/React.createElement("select", { value: exportSettings.bitDepth, @@ -8707,21 +8923,42 @@ const App = () => { })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { + value: "8" + }, "8"), /*#__PURE__*/React.createElement("option", { value: "16" }, "16"), /*#__PURE__*/React.createElement("option", { value: "24" - }, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + }, "24")))) : /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" - }, "Fmt"), /*#__PURE__*/React.createElement("select", { - value: exportSettings.format, + }, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.quality, onChange: e => setExportSettings(p => ({ ...p, - format: e.target.value + quality: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { - value: "wav" - }, "WAV")))), /*#__PURE__*/React.createElement("button", { + value: "44khz" + }, "44kHz"), /*#__PURE__*/React.createElement("option", { + value: "lossless" + }, "Lossless"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Kênh"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.channels, + onChange: e => setExportSettings(p => ({ + ...p, + channels: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "mono" + }, "Mono"), /*#__PURE__*/React.createElement("option", { + value: "stereo" + }, "Stereo"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("button", { onClick: triggerWavExport, disabled: isExporting, className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1" @@ -8843,9 +9080,9 @@ const App = () => { }, "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.5 mt-1 flex-1 min-h-0 flex flex-col" + 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-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between shrink-0" + 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", { @@ -8872,9 +9109,9 @@ const App = () => { }]); } }, - className: "text-xs text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0.5" + 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 p-1 select-text" + 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", { diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 4a4340d..3186013 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -227,6 +227,25 @@ const AIGateway = (function() { }, required: ['item_id', 'notes'] } + }, { + 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'] + } }]; function parseOrigin(urlStr) { @@ -324,7 +343,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 (SonicForge Studio). Các lệnh DAW có sẵn:\n${toolNames}\n\nQUAN TRỌNG: Bar được đánh số từ 0 (bar 0 = bar đầu tiên). VD: bar 0-3 = 4 bar đầu tiên.\nPhân tích yêu cầu và trả về FUNCTION CALLS. Có thể gọi nhiều function cùng lúc.` }, + { 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: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` } ]; } diff --git a/app/static/js/services/dawCommandDispatcher.js b/app/static/js/services/dawCommandDispatcher.js index 72a9e37..7fb2bbd 100644 --- a/app/static/js/services/dawCommandDispatcher.js +++ b/app/static/js/services/dawCommandDispatcher.js @@ -61,6 +61,7 @@ const DAWCommandDispatcher = (function() { register('SCAN_TRACK', (args) => api.scanTrack(args)); register('CUT_AUDIO', (args) => api.cutAudio(args)); register('SET_SELECTION', (args) => api.setSelection(args)); + register('EXPORT_AUDIO', (args) => api.exportAudio(args)); register('SET_BPM', (args) => api.setBpm(args)); register('SET_PLAYHEAD', (args) => api.setPlayhead(args)); register('SELECT_ITEM', (args) => api.selectItem(args)); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 5c960fe2f778a8e7a5471bc67e33f65d09156bd9..49fd79bf77804970e168c9dd87de2cbec3473c9c 100644 GIT binary patch delta 108 zcmZp8z|`=7X@V3Jqv%8#Cm^{oK|r37b8?})lY?cFp{238nR$w7lDV;op{Yr-X>ww6 zQd*jYg;AQBv6+Qonvq3PvZ;xAl7XR_u|Y~&N{WGDqOoOKnrX6;p~2?Q^4sHC#f2l! LyKi1!A7}soQaK;2 delta 108 zcmZp8z|`=7X@V3J!|I7LPC#;Ff`B|D$K*nJCkI1Q!&GxqOOrHH3o~9 z<1`~9W6KnCgVa<@GlR5bGb77n!?ctnL&LPB

8A0}G>63j=c#%e2j(<+sPP^3V9J L|8?{F`alB!vH&5d