fix: sửa copilot không gửi AI provider

This commit is contained in:
2026-07-22 15:02:43 +07:00
parent b78193dfad
commit 8a85dd2dfc
5 changed files with 620 additions and 221 deletions
+254 -112
View File
@@ -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", {
+345 -108
View File
@@ -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", {
+20 -1
View File
@@ -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}` }
];
}
@@ -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));
Binary file not shown.