fix: sửa copilot không gửi AI provider
This commit is contained in:
+239
-97
@@ -3094,7 +3094,10 @@ const App = () => {
|
|||||||
const [exportSettings, setExportSettings] = useState({
|
const [exportSettings, setExportSettings] = useState({
|
||||||
sampleRate: '44100',
|
sampleRate: '44100',
|
||||||
bitDepth: '16',
|
bitDepth: '16',
|
||||||
format: 'wav'
|
format: 'wav',
|
||||||
|
source: 'project',
|
||||||
|
quality: '44khz',
|
||||||
|
channels: 'stereo'
|
||||||
});
|
});
|
||||||
const [serverStatus, setServerStatus] = useState('checking...');
|
const [serverStatus, setServerStatus] = useState('checking...');
|
||||||
const [menuOpen, setMenuOpen] = useState(null);
|
const [menuOpen, setMenuOpen] = useState(null);
|
||||||
@@ -3636,7 +3639,10 @@ const App = () => {
|
|||||||
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||||
clipboardRef.current = {
|
clipboardRef.current = {
|
||||||
buffer: copyBuffer,
|
buffer: copyBuffer,
|
||||||
name: 'Subtab Clip'
|
name: 'Subtab Clip',
|
||||||
|
sampleRate: sr,
|
||||||
|
channels: 1,
|
||||||
|
speed: 1.0
|
||||||
};
|
};
|
||||||
showToast('Đã Copy vùng chọn.', 'success');
|
showToast('Đã Copy vùng chọn.', 'success');
|
||||||
};
|
};
|
||||||
@@ -4581,14 +4587,20 @@ const App = () => {
|
|||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
const numCh = track.buffer.numberOfChannels || 1;
|
||||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
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 = {
|
clipboardRef.current = {
|
||||||
buffer: clipBuffer,
|
buffer: clipBuffer,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
volumeDb: track.volumeDb,
|
volumeDb: track.volumeDb,
|
||||||
pan: track.pan,
|
pan: track.pan,
|
||||||
color: track.color
|
color: track.color,
|
||||||
|
sampleRate: clipBuffer.sampleRate,
|
||||||
|
channels: numCh,
|
||||||
|
speed: track.speed || 1.0
|
||||||
};
|
};
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
showToast('Đã sao chép vùng chọn.', 'info');
|
showToast('Đã sao chép vùng chọn.', 'info');
|
||||||
@@ -4602,7 +4614,10 @@ const App = () => {
|
|||||||
name: track.name,
|
name: track.name,
|
||||||
volumeDb: track.volumeDb,
|
volumeDb: track.volumeDb,
|
||||||
pan: track.pan,
|
pan: track.pan,
|
||||||
color: track.color
|
color: track.color,
|
||||||
|
sampleRate: track.buffer.sampleRate,
|
||||||
|
channels: track.buffer.numberOfChannels,
|
||||||
|
speed: track.speed || 1.0
|
||||||
};
|
};
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
showToast('Đã sao chép toàn bộ track.', 'info');
|
showToast('Đã sao chép toàn bộ track.', 'info');
|
||||||
@@ -4625,20 +4640,30 @@ const App = () => {
|
|||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
const numCh = t.buffer.numberOfChannels || 1;
|
||||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
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 = {
|
clipboardRef.current = {
|
||||||
buffer: clipBuffer,
|
buffer: clipBuffer,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
volumeDb: t.volumeDb,
|
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 newLen = data.length - len;
|
||||||
const newBuffer = ctx.createBuffer(1, newLen, sr);
|
const newBuffer = ctx.createBuffer(numCh, newLen, sr);
|
||||||
const newData = newBuffer.getChannelData(0);
|
for (let ch = 0; ch < numCh; ch++) {
|
||||||
|
const src = t.buffer.getChannelData(ch);
|
||||||
|
const dst = newBuffer.getChannelData(ch);
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
|
for (let i = 0; i < startSample; i++) dst[idx++] = src[i];
|
||||||
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
|
for (let i = endSample; i < src.length; i++) dst[idx++] = src[i];
|
||||||
|
}
|
||||||
setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? {
|
setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? {
|
||||||
...tr,
|
...tr,
|
||||||
buffer: newBuffer
|
buffer: newBuffer
|
||||||
@@ -4663,7 +4688,10 @@ const App = () => {
|
|||||||
name,
|
name,
|
||||||
volumeDb,
|
volumeDb,
|
||||||
pan,
|
pan,
|
||||||
color
|
color,
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
speed
|
||||||
} = clipboardRef.current;
|
} = clipboardRef.current;
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
||||||
@@ -4671,7 +4699,11 @@ const App = () => {
|
|||||||
id: nextClipId(),
|
id: nextClipId(),
|
||||||
startTime: pasteTime,
|
startTime: pasteTime,
|
||||||
buffer: clipBuffer,
|
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) {
|
if (targetTrack) {
|
||||||
setTracks(p => p.map(t => {
|
setTracks(p => p.map(t => {
|
||||||
@@ -4852,10 +4884,14 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
// No selection: copy entire track
|
// No selection: copy entire track
|
||||||
clipboardRef.current = {
|
clipboardRef.current = {
|
||||||
buffer: t.buffer,
|
buffer: clipBuffer,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
volumeDb: t.volumeDb,
|
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');
|
showToast('Copied track to clipboard.', 'info');
|
||||||
};
|
};
|
||||||
@@ -5226,16 +5262,15 @@ const App = () => {
|
|||||||
// If selection cleared by user, play linearly (don't loop)
|
// If selection cleared by user, play linearly (don't loop)
|
||||||
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
||||||
if (selRight > selLeft && updatedTime >= selRight) {
|
if (selRight > selLeft && updatedTime >= selRight) {
|
||||||
if (selectionMode === 'local') {
|
if (soloedTrackId !== null || selectionMode === 'local') {
|
||||||
// Local Solo Loop: only restart the selected track
|
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
startOffsetTimeRef.current = selLeft;
|
startOffsetTimeRef.current = selLeft;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId;
|
||||||
|
startLocalTrackPlayback(soloTid, selLeft);
|
||||||
setCurrentTime(selLeft);
|
setCurrentTime(selLeft);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
} else {
|
} else {
|
||||||
// Global Master Loop: restart all tracks
|
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
startOffsetTimeRef.current = selLeft;
|
startOffsetTimeRef.current = selLeft;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
@@ -5615,11 +5650,18 @@ const App = () => {
|
|||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
const beforeSnap = captureTrackSnapshot(trackId);
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
if (isDuplicate) {
|
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({
|
setDraggedClip({
|
||||||
trackId, clipId: clip.id, clickOffset,
|
trackId, clipId: cloneId, clickOffset, buffer: clip.buffer,
|
||||||
buffer: clip.buffer, name: clip.name, beforeSnap,
|
name: clip.name + ' (Copy)', beforeSnap, isDuplicate: false
|
||||||
isDuplicate: true,
|
|
||||||
origTrackId: trackId, origClipId: clip.id
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5791,22 +5833,10 @@ const App = () => {
|
|||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
const drag = draggedClipRef.current;
|
const drag = draggedClipRef.current;
|
||||||
if (!drag) return;
|
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);
|
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);
|
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('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -6199,21 +6229,53 @@ const App = () => {
|
|||||||
|
|
||||||
// ── Server-side Export ──
|
// ── Server-side Export ──
|
||||||
const triggerWavExport = async () => {
|
const triggerWavExport = async () => {
|
||||||
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
|
let exportTracks;
|
||||||
if (activeTracks.length === 0) {
|
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");
|
showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all active tracks have server file IDs
|
// 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') {
|
if (allOnServer && serverStatus === 'connected') {
|
||||||
// Use server-side export
|
// Use server-side export
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
|
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
|
||||||
try {
|
try {
|
||||||
const sessionId = `session_${Date.now()}`;
|
const sessionId = `session_${Date.now()}`;
|
||||||
const tracksMeta = activeTracks.map(t => ({
|
const tracksMeta = exportTracks.map(t => ({
|
||||||
track_id: t.id,
|
track_id: t.id,
|
||||||
file_id: serverFileIdMap[t.id],
|
file_id: serverFileIdMap[t.id],
|
||||||
volume_db: t.volumeDb,
|
volume_db: t.volumeDb,
|
||||||
@@ -6238,7 +6300,8 @@ const App = () => {
|
|||||||
export_settings: {
|
export_settings: {
|
||||||
sample_rate: parseInt(exportSettings.sampleRate),
|
sample_rate: parseInt(exportSettings.sampleRate),
|
||||||
bit_depth: parseInt(exportSettings.bitDepth),
|
bit_depth: parseInt(exportSettings.bitDepth),
|
||||||
format: exportSettings.format
|
format: exportSettings.format,
|
||||||
|
channels: exportSettings.channels
|
||||||
},
|
},
|
||||||
tracks: tracksMeta
|
tracks: tracksMeta
|
||||||
})
|
})
|
||||||
@@ -6266,13 +6329,13 @@ const App = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
|
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
|
||||||
// Fall back to client-side export
|
// Fall back to client-side export
|
||||||
clientSideExport(activeTracks);
|
clientSideExport(exportTracks);
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Client-side export (existing working code)
|
// Client-side export (existing working code)
|
||||||
clientSideExport(activeTracks);
|
clientSideExport(exportTracks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleSaveCloud = async () => {
|
const handleSaveCloud = async () => {
|
||||||
@@ -6382,7 +6445,8 @@ const App = () => {
|
|||||||
if (clips.length === 0) return 0;
|
if (clips.length === 0) return 0;
|
||||||
return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.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 => {
|
activeTracks.forEach(t => {
|
||||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -6410,11 +6474,11 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const renderedBuffer = await offlineCtx.startRendering();
|
const renderedBuffer = await offlineCtx.startRendering();
|
||||||
const monoData = renderedBuffer.getChannelData(0);
|
const numExportCh = renderedBuffer.numberOfChannels;
|
||||||
const bufferLength = monoData.length;
|
const exportLength = renderedBuffer.length;
|
||||||
const bytesPerSample = bitDepth / 8;
|
const bytesPerSample = bitDepth / 8;
|
||||||
const headerSize = 44;
|
const headerSize = 44;
|
||||||
const fileSizeBytes = headerSize + bufferLength * bytesPerSample;
|
const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh;
|
||||||
const fileBuffer = new ArrayBuffer(fileSizeBytes);
|
const fileBuffer = new ArrayBuffer(fileSizeBytes);
|
||||||
const view = new DataView(fileBuffer);
|
const view = new DataView(fileBuffer);
|
||||||
const writeString = (offset, string) => {
|
const writeString = (offset, string) => {
|
||||||
@@ -6428,16 +6492,18 @@ const App = () => {
|
|||||||
writeString(12, 'fmt ');
|
writeString(12, 'fmt ');
|
||||||
view.setUint32(16, 16, true);
|
view.setUint32(16, 16, true);
|
||||||
view.setUint16(20, 1, true);
|
view.setUint16(20, 1, true);
|
||||||
view.setUint16(22, 1, true);
|
view.setUint16(22, numExportCh, true);
|
||||||
view.setUint32(24, targetRate, 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(32, bytesPerSample, true);
|
||||||
view.setUint16(34, bitDepth, true);
|
view.setUint16(34, bitDepth, true);
|
||||||
writeString(36, 'data');
|
writeString(36, 'data');
|
||||||
view.setUint32(40, bufferLength * bytesPerSample, true);
|
view.setUint32(40, exportLength * bytesPerSample * numExportCh, true);
|
||||||
let offset = 44;
|
let offset = 44;
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
for (let i = 0; i < exportLength; i++) {
|
||||||
const sample = Math.max(-1, Math.min(1, monoData[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) {
|
if (bitDepth === 8) {
|
||||||
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
|
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
|
||||||
} else if (bitDepth === 16) {
|
} else if (bitDepth === 16) {
|
||||||
@@ -6450,6 +6516,7 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
offset += bytesPerSample;
|
offset += bytesPerSample;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const blob = new Blob([view], {
|
const blob = new Blob([view], {
|
||||||
type: 'audio/wav'
|
type: 'audio/wav'
|
||||||
});
|
});
|
||||||
@@ -7047,19 +7114,30 @@ const App = () => {
|
|||||||
setAiProcessing(true);
|
setAiProcessing(true);
|
||||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]);
|
setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]);
|
||||||
try {
|
try {
|
||||||
const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null);
|
if (aiProviders.length === 0 || !selectedProviderId) {
|
||||||
const provider = selectedProvider || aiConfig;
|
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 baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`;
|
||||||
const apiKey = provider.api_key || provider.apiKey || '';
|
const apiKey = provider.api_key || provider.apiKey || '';
|
||||||
const model = provider.model_name || provider.model || 'deepseek-chat';
|
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({
|
const dawContext = window.AIGateway.buildAIPromptContext({
|
||||||
tracks, bpm, selectedTrackId, currentTime, selLeft, selRight
|
tracks, bpm, selectedTrackId, currentTime, selLeft, selRight
|
||||||
});
|
});
|
||||||
const result = await window.AIGateway.executeAIPrompt({
|
const result = await window.AIGateway.executeAIPrompt({
|
||||||
prompt,
|
prompt: prompt,
|
||||||
provider: provider.name || 'default',
|
provider: provider.name || 'default',
|
||||||
model,
|
model: model,
|
||||||
apiKey,
|
apiKey: apiKey,
|
||||||
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
||||||
dawContext,
|
dawContext,
|
||||||
tools: window.AIGateway.DEFAULT_TOOLS
|
tools: window.AIGateway.DEFAULT_TOOLS
|
||||||
@@ -7076,8 +7154,9 @@ const App = () => {
|
|||||||
const cmdName = fc.name.toUpperCase();
|
const cmdName = fc.name.toUpperCase();
|
||||||
if (window.DAWCommandDispatcher) {
|
if (window.DAWCommandDispatcher) {
|
||||||
try {
|
try {
|
||||||
const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments);
|
let 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() }]);
|
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) {
|
} catch (cmdErr) {
|
||||||
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]);
|
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]);
|
||||||
}
|
}
|
||||||
@@ -7087,7 +7166,9 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasText && !hasCalls) {
|
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) {
|
if (prompt) {
|
||||||
promptHistRef.current = [...promptHistRef.current.slice(-49), prompt];
|
promptHistRef.current = [...promptHistRef.current.slice(-49), prompt];
|
||||||
@@ -7524,6 +7605,62 @@ const App = () => {
|
|||||||
handlePlayheadSet(time);
|
handlePlayheadSet(time);
|
||||||
return { success: true, time: parseFloat(time.toFixed(3)) };
|
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) => {
|
selectItem: (args) => {
|
||||||
if (args.select_all) {
|
if (args.select_all) {
|
||||||
setSelectedTrackId(null);
|
setSelectedTrackId(null);
|
||||||
@@ -8384,45 +8521,50 @@ const App = () => {
|
|||||||
"data-lucide": "x",
|
"data-lucide": "x",
|
||||||
className: "w-3 h-3"
|
className: "w-3 h-3"
|
||||||
})))), /*#__PURE__*/React.createElement("div", {
|
})))), /*#__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", {
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
||||||
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
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.sampleRate,
|
value: exportSettings.source,
|
||||||
onChange: e => setExportSettings(p => ({
|
onChange: e => setExportSettings(p => ({ ...p, source: e.target.value })),
|
||||||
...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"
|
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", {
|
}, /*#__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", {
|
||||||
value: "44100"
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||||
}, "44.1k"), /*#__PURE__*/React.createElement("option", {
|
}, "\u0110\u1ecbnh d\u1ea1ng"), /*#__PURE__*/React.createElement("select", {
|
||||||
value: "48000"
|
value: exportSettings.format,
|
||||||
}, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
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"
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||||
}, "Bit"), /*#__PURE__*/React.createElement("select", {
|
}, "Bit"), /*#__PURE__*/React.createElement("select", {
|
||||||
value: exportSettings.bitDepth,
|
value: exportSettings.bitDepth,
|
||||||
onChange: e => setExportSettings(p => ({
|
onChange: e => setExportSettings(p => ({ ...p, bitDepth: e.target.value })),
|
||||||
...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"
|
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", {
|
}, /*#__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", {
|
||||||
value: "16"
|
className: "grid grid-cols-2 gap-1"
|
||||||
}, "16"), /*#__PURE__*/React.createElement("option", {
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
||||||
value: "24"
|
|
||||||
}, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
||||||
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||||
}, "Fmt"), /*#__PURE__*/React.createElement("select", {
|
}, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", {
|
||||||
value: exportSettings.format,
|
value: exportSettings.quality,
|
||||||
onChange: e => setExportSettings(p => ({
|
onChange: e => setExportSettings(p => ({ ...p, quality: e.target.value })),
|
||||||
...p,
|
|
||||||
format: 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"
|
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", {
|
}, /*#__PURE__*/React.createElement("option", { value: "44khz" }, "44kHz"), /*#__PURE__*/React.createElement("option", { value: "lossless" }, "Lossless"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("div", {
|
||||||
value: "wav"
|
className: "grid grid-cols-2 gap-1"
|
||||||
}, "WAV")))), /*#__PURE__*/React.createElement("button", {
|
}, /*#__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,
|
onClick: triggerWavExport,
|
||||||
disabled: isExporting,
|
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"
|
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", {
|
}, "Clear")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "text-xs text-zinc-600 mt-0.5"
|
className: "text-xs text-zinc-600 mt-0.5"
|
||||||
}, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", {
|
}, "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", {
|
}, /*#__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", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
className: "inline-flex items-center gap-1"
|
className: "inline-flex items-center gap-1"
|
||||||
}, /*#__PURE__*/React.createElement("i", {
|
}, /*#__PURE__*/React.createElement("i", {
|
||||||
@@ -8565,9 +8707,9 @@ const App = () => {
|
|||||||
setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]);
|
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", {
|
}, "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", {
|
}, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||||
className: "text-xs text-zinc-600 italic select-text"
|
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", {
|
}, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", {
|
||||||
|
|||||||
@@ -3137,7 +3137,10 @@ const App = () => {
|
|||||||
const [exportSettings, setExportSettings] = useState({
|
const [exportSettings, setExportSettings] = useState({
|
||||||
sampleRate: '44100',
|
sampleRate: '44100',
|
||||||
bitDepth: '16',
|
bitDepth: '16',
|
||||||
format: 'wav'
|
format: 'wav',
|
||||||
|
source: 'project',
|
||||||
|
quality: '44khz',
|
||||||
|
channels: 'stereo'
|
||||||
});
|
});
|
||||||
const [serverStatus, setServerStatus] = useState('checking...');
|
const [serverStatus, setServerStatus] = useState('checking...');
|
||||||
const [menuOpen, setMenuOpen] = useState(null);
|
const [menuOpen, setMenuOpen] = useState(null);
|
||||||
@@ -3685,7 +3688,10 @@ const App = () => {
|
|||||||
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||||
clipboardRef.current = {
|
clipboardRef.current = {
|
||||||
buffer: copyBuffer,
|
buffer: copyBuffer,
|
||||||
name: 'Subtab Clip'
|
name: 'Subtab Clip',
|
||||||
|
sampleRate: sr,
|
||||||
|
channels: 1,
|
||||||
|
speed: 1.0
|
||||||
};
|
};
|
||||||
showToast('Đã Copy vùng chọn.', 'success');
|
showToast('Đã Copy vùng chọn.', 'success');
|
||||||
};
|
};
|
||||||
@@ -4630,14 +4636,20 @@ const App = () => {
|
|||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
const numCh = track.buffer.numberOfChannels || 1;
|
||||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
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 = {
|
clipboardRef.current = {
|
||||||
buffer: clipBuffer,
|
buffer: clipBuffer,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
volumeDb: track.volumeDb,
|
volumeDb: track.volumeDb,
|
||||||
pan: track.pan,
|
pan: track.pan,
|
||||||
color: track.color
|
color: track.color,
|
||||||
|
sampleRate: clipBuffer.sampleRate,
|
||||||
|
channels: numCh,
|
||||||
|
speed: track.speed || 1.0
|
||||||
};
|
};
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
showToast('Đã sao chép vùng chọn.', 'info');
|
showToast('Đã sao chép vùng chọn.', 'info');
|
||||||
@@ -4651,7 +4663,10 @@ const App = () => {
|
|||||||
name: track.name,
|
name: track.name,
|
||||||
volumeDb: track.volumeDb,
|
volumeDb: track.volumeDb,
|
||||||
pan: track.pan,
|
pan: track.pan,
|
||||||
color: track.color
|
color: track.color,
|
||||||
|
sampleRate: track.buffer.sampleRate,
|
||||||
|
channels: track.buffer.numberOfChannels,
|
||||||
|
speed: track.speed || 1.0
|
||||||
};
|
};
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
showToast('Đã sao chép toàn bộ track.', 'info');
|
showToast('Đã sao chép toàn bộ track.', 'info');
|
||||||
@@ -4674,20 +4689,30 @@ const App = () => {
|
|||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
const numCh = t.buffer.numberOfChannels || 1;
|
||||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
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 = {
|
clipboardRef.current = {
|
||||||
buffer: clipBuffer,
|
buffer: clipBuffer,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
volumeDb: t.volumeDb,
|
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 newLen = data.length - len;
|
||||||
const newBuffer = ctx.createBuffer(1, newLen, sr);
|
const newBuffer = ctx.createBuffer(numCh, newLen, sr);
|
||||||
const newData = newBuffer.getChannelData(0);
|
for (let ch = 0; ch < numCh; ch++) {
|
||||||
|
const src = t.buffer.getChannelData(ch);
|
||||||
|
const dst = newBuffer.getChannelData(ch);
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
|
for (let i = 0; i < startSample; i++) dst[idx++] = src[i];
|
||||||
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
|
for (let i = endSample; i < src.length; i++) dst[idx++] = src[i];
|
||||||
|
}
|
||||||
setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? {
|
setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? {
|
||||||
...tr,
|
...tr,
|
||||||
buffer: newBuffer
|
buffer: newBuffer
|
||||||
@@ -4712,7 +4737,10 @@ const App = () => {
|
|||||||
name,
|
name,
|
||||||
volumeDb,
|
volumeDb,
|
||||||
pan,
|
pan,
|
||||||
color
|
color,
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
speed
|
||||||
} = clipboardRef.current;
|
} = clipboardRef.current;
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
||||||
@@ -4720,7 +4748,19 @@ const App = () => {
|
|||||||
id: nextClipId(),
|
id: nextClipId(),
|
||||||
startTime: pasteTime,
|
startTime: pasteTime,
|
||||||
buffer: clipBuffer,
|
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) {
|
if (targetTrack) {
|
||||||
setTracks(p => p.map(t => {
|
setTracks(p => p.map(t => {
|
||||||
@@ -4901,10 +4941,14 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
// No selection: copy entire track
|
// No selection: copy entire track
|
||||||
clipboardRef.current = {
|
clipboardRef.current = {
|
||||||
buffer: t.buffer,
|
buffer: clipBuffer,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
volumeDb: t.volumeDb,
|
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');
|
showToast('Copied track to clipboard.', 'info');
|
||||||
};
|
};
|
||||||
@@ -5275,16 +5319,15 @@ const App = () => {
|
|||||||
// If selection cleared by user, play linearly (don't loop)
|
// If selection cleared by user, play linearly (don't loop)
|
||||||
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
||||||
if (selRight > selLeft && updatedTime >= selRight) {
|
if (selRight > selLeft && updatedTime >= selRight) {
|
||||||
if (selectionMode === 'local') {
|
if (soloedTrackId !== null || selectionMode === 'local') {
|
||||||
// Local Solo Loop: only restart the selected track
|
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
startOffsetTimeRef.current = selLeft;
|
startOffsetTimeRef.current = selLeft;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId;
|
||||||
|
startLocalTrackPlayback(soloTid, selLeft);
|
||||||
setCurrentTime(selLeft);
|
setCurrentTime(selLeft);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
} else {
|
} else {
|
||||||
// Global Master Loop: restart all tracks
|
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
startOffsetTimeRef.current = selLeft;
|
startOffsetTimeRef.current = selLeft;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
@@ -5663,16 +5706,34 @@ const App = () => {
|
|||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
const beforeSnap = captureTrackSnapshot(trackId);
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
if (isDuplicate) {
|
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({
|
setDraggedClip({
|
||||||
trackId,
|
trackId,
|
||||||
clipId: clip.id,
|
clipId: cloneId,
|
||||||
clickOffset,
|
clickOffset,
|
||||||
buffer: clip.buffer,
|
buffer: clip.buffer,
|
||||||
name: clip.name,
|
name: clip.name + ' (Copy)',
|
||||||
beforeSnap,
|
beforeSnap,
|
||||||
isDuplicate: true,
|
isDuplicate: false
|
||||||
origTrackId: trackId,
|
|
||||||
origClipId: clip.id
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5851,40 +5912,10 @@ const App = () => {
|
|||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
const drag = draggedClipRef.current;
|
const drag = draggedClipRef.current;
|
||||||
if (!drag) return;
|
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);
|
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);
|
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('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -6283,21 +6314,72 @@ const App = () => {
|
|||||||
|
|
||||||
// ── Server-side Export ──
|
// ── Server-side Export ──
|
||||||
const triggerWavExport = async () => {
|
const triggerWavExport = async () => {
|
||||||
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
|
let exportTracks;
|
||||||
if (activeTracks.length === 0) {
|
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");
|
showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all active tracks have server file IDs
|
// 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') {
|
if (allOnServer && serverStatus === 'connected') {
|
||||||
// Use server-side export
|
// Use server-side export
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
|
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
|
||||||
try {
|
try {
|
||||||
const sessionId = `session_${Date.now()}`;
|
const sessionId = `session_${Date.now()}`;
|
||||||
const tracksMeta = activeTracks.map(t => ({
|
const tracksMeta = exportTracks.map(t => ({
|
||||||
track_id: t.id,
|
track_id: t.id,
|
||||||
file_id: serverFileIdMap[t.id],
|
file_id: serverFileIdMap[t.id],
|
||||||
volume_db: t.volumeDb,
|
volume_db: t.volumeDb,
|
||||||
@@ -6322,7 +6404,8 @@ const App = () => {
|
|||||||
export_settings: {
|
export_settings: {
|
||||||
sample_rate: parseInt(exportSettings.sampleRate),
|
sample_rate: parseInt(exportSettings.sampleRate),
|
||||||
bit_depth: parseInt(exportSettings.bitDepth),
|
bit_depth: parseInt(exportSettings.bitDepth),
|
||||||
format: exportSettings.format
|
format: exportSettings.format,
|
||||||
|
channels: exportSettings.channels
|
||||||
},
|
},
|
||||||
tracks: tracksMeta
|
tracks: tracksMeta
|
||||||
})
|
})
|
||||||
@@ -6350,13 +6433,13 @@ const App = () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
|
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
|
||||||
// Fall back to client-side export
|
// Fall back to client-side export
|
||||||
clientSideExport(activeTracks);
|
clientSideExport(exportTracks);
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Client-side export (existing working code)
|
// Client-side export (existing working code)
|
||||||
clientSideExport(activeTracks);
|
clientSideExport(exportTracks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleSaveCloud = async () => {
|
const handleSaveCloud = async () => {
|
||||||
@@ -6466,7 +6549,8 @@ const App = () => {
|
|||||||
if (clips.length === 0) return 0;
|
if (clips.length === 0) return 0;
|
||||||
return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.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 => {
|
activeTracks.forEach(t => {
|
||||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -6494,11 +6578,11 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const renderedBuffer = await offlineCtx.startRendering();
|
const renderedBuffer = await offlineCtx.startRendering();
|
||||||
const monoData = renderedBuffer.getChannelData(0);
|
const numExportCh = renderedBuffer.numberOfChannels;
|
||||||
const bufferLength = monoData.length;
|
const exportLength = renderedBuffer.length;
|
||||||
const bytesPerSample = bitDepth / 8;
|
const bytesPerSample = bitDepth / 8;
|
||||||
const headerSize = 44;
|
const headerSize = 44;
|
||||||
const fileSizeBytes = headerSize + bufferLength * bytesPerSample;
|
const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh;
|
||||||
const fileBuffer = new ArrayBuffer(fileSizeBytes);
|
const fileBuffer = new ArrayBuffer(fileSizeBytes);
|
||||||
const view = new DataView(fileBuffer);
|
const view = new DataView(fileBuffer);
|
||||||
const writeString = (offset, string) => {
|
const writeString = (offset, string) => {
|
||||||
@@ -6512,16 +6596,18 @@ const App = () => {
|
|||||||
writeString(12, 'fmt ');
|
writeString(12, 'fmt ');
|
||||||
view.setUint32(16, 16, true);
|
view.setUint32(16, 16, true);
|
||||||
view.setUint16(20, 1, true);
|
view.setUint16(20, 1, true);
|
||||||
view.setUint16(22, 1, true);
|
view.setUint16(22, numExportCh, true);
|
||||||
view.setUint32(24, targetRate, 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(32, bytesPerSample, true);
|
||||||
view.setUint16(34, bitDepth, true);
|
view.setUint16(34, bitDepth, true);
|
||||||
writeString(36, 'data');
|
writeString(36, 'data');
|
||||||
view.setUint32(40, bufferLength * bytesPerSample, true);
|
view.setUint32(40, exportLength * bytesPerSample * numExportCh, true);
|
||||||
let offset = 44;
|
let offset = 44;
|
||||||
for (let i = 0; i < bufferLength; i++) {
|
for (let i = 0; i < exportLength; i++) {
|
||||||
const sample = Math.max(-1, Math.min(1, monoData[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) {
|
if (bitDepth === 8) {
|
||||||
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
|
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
|
||||||
} else if (bitDepth === 16) {
|
} else if (bitDepth === 16) {
|
||||||
@@ -6534,6 +6620,7 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
offset += bytesPerSample;
|
offset += bytesPerSample;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const blob = new Blob([view], {
|
const blob = new Blob([view], {
|
||||||
type: 'audio/wav'
|
type: 'audio/wav'
|
||||||
});
|
});
|
||||||
@@ -7138,11 +7225,26 @@ const App = () => {
|
|||||||
time: Date.now()
|
time: Date.now()
|
||||||
}]);
|
}]);
|
||||||
try {
|
try {
|
||||||
const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null);
|
if (aiProviders.length === 0 || !selectedProviderId) {
|
||||||
const provider = selectedProvider || aiConfig;
|
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 baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`;
|
||||||
const apiKey = provider.api_key || provider.apiKey || '';
|
const apiKey = provider.api_key || provider.apiKey || '';
|
||||||
const model = provider.model_name || provider.model || 'deepseek-chat';
|
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({
|
const dawContext = window.AIGateway.buildAIPromptContext({
|
||||||
tracks,
|
tracks,
|
||||||
bpm,
|
bpm,
|
||||||
@@ -7152,10 +7254,10 @@ const App = () => {
|
|||||||
selRight
|
selRight
|
||||||
});
|
});
|
||||||
const result = await window.AIGateway.executeAIPrompt({
|
const result = await window.AIGateway.executeAIPrompt({
|
||||||
prompt,
|
prompt: prompt,
|
||||||
provider: provider.name || 'default',
|
provider: provider.name || 'default',
|
||||||
model,
|
model: model,
|
||||||
apiKey,
|
apiKey: apiKey,
|
||||||
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
||||||
dawContext,
|
dawContext,
|
||||||
tools: window.AIGateway.DEFAULT_TOOLS
|
tools: window.AIGateway.DEFAULT_TOOLS
|
||||||
@@ -7184,10 +7286,11 @@ const App = () => {
|
|||||||
const cmdName = fc.name.toUpperCase();
|
const cmdName = fc.name.toUpperCase();
|
||||||
if (window.DAWCommandDispatcher) {
|
if (window.DAWCommandDispatcher) {
|
||||||
try {
|
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, {
|
setAiActionLog(prev => [...prev, {
|
||||||
type: 'status',
|
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()
|
time: Date.now()
|
||||||
}]);
|
}]);
|
||||||
} catch (cmdErr) {
|
} catch (cmdErr) {
|
||||||
@@ -7207,9 +7310,11 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasText && !hasCalls) {
|
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, {
|
setAiActionLog(prev => [...prev, {
|
||||||
type: 'error',
|
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()
|
time: Date.now()
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
@@ -7779,6 +7884,80 @@ const App = () => {
|
|||||||
time: parseFloat(time.toFixed(3))
|
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 => {
|
selectItem: args => {
|
||||||
if (args.select_all) {
|
if (args.select_all) {
|
||||||
setSelectedTrackId(null);
|
setSelectedTrackId(null);
|
||||||
@@ -8683,10 +8862,47 @@ const App = () => {
|
|||||||
"data-lucide": "x",
|
"data-lucide": "x",
|
||||||
className: "w-3 h-3"
|
className: "w-3 h-3"
|
||||||
})))), /*#__PURE__*/React.createElement("div", {
|
})))), /*#__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", {
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
||||||
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
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,
|
value: exportSettings.sampleRate,
|
||||||
onChange: e => setExportSettings(p => ({
|
onChange: e => setExportSettings(p => ({
|
||||||
...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"
|
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", {
|
}, /*#__PURE__*/React.createElement("option", {
|
||||||
|
value: "22500"
|
||||||
|
}, "22500"), /*#__PURE__*/React.createElement("option", {
|
||||||
value: "44100"
|
value: "44100"
|
||||||
}, "44.1k"), /*#__PURE__*/React.createElement("option", {
|
}, "44100"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
||||||
value: "48000"
|
|
||||||
}, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
||||||
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||||
}, "Bit"), /*#__PURE__*/React.createElement("select", {
|
}, "Bit"), /*#__PURE__*/React.createElement("select", {
|
||||||
value: exportSettings.bitDepth,
|
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"
|
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", {
|
}, /*#__PURE__*/React.createElement("option", {
|
||||||
|
value: "8"
|
||||||
|
}, "8"), /*#__PURE__*/React.createElement("option", {
|
||||||
value: "16"
|
value: "16"
|
||||||
}, "16"), /*#__PURE__*/React.createElement("option", {
|
}, "16"), /*#__PURE__*/React.createElement("option", {
|
||||||
value: "24"
|
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"
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||||
}, "Fmt"), /*#__PURE__*/React.createElement("select", {
|
}, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", {
|
||||||
value: exportSettings.format,
|
value: exportSettings.quality,
|
||||||
onChange: e => setExportSettings(p => ({
|
onChange: e => setExportSettings(p => ({
|
||||||
...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"
|
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", {
|
}, /*#__PURE__*/React.createElement("option", {
|
||||||
value: "wav"
|
value: "44khz"
|
||||||
}, "WAV")))), /*#__PURE__*/React.createElement("button", {
|
}, "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,
|
onClick: triggerWavExport,
|
||||||
disabled: isExporting,
|
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"
|
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", {
|
}, "Clear")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "text-xs text-zinc-600 mt-0.5"
|
className: "text-xs text-zinc-600 mt-0.5"
|
||||||
}, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", {
|
}, "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", {
|
}, /*#__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", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
className: "inline-flex items-center gap-1"
|
className: "inline-flex items-center gap-1"
|
||||||
}, /*#__PURE__*/React.createElement("i", {
|
}, /*#__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", {
|
}, "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", {
|
}, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||||
className: "text-xs text-zinc-600 italic select-text"
|
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", {
|
}, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", {
|
||||||
|
|||||||
@@ -227,6 +227,25 @@ const AIGateway = (function() {
|
|||||||
},
|
},
|
||||||
required: ['item_id', 'notes']
|
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) {
|
function parseOrigin(urlStr) {
|
||||||
@@ -324,7 +343,7 @@ const AIGateway = (function() {
|
|||||||
const contextStr = JSON.stringify(context, null, 2);
|
const contextStr = JSON.stringify(context, null, 2);
|
||||||
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
||||||
return [
|
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}` }
|
{ 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('SCAN_TRACK', (args) => api.scanTrack(args));
|
||||||
register('CUT_AUDIO', (args) => api.cutAudio(args));
|
register('CUT_AUDIO', (args) => api.cutAudio(args));
|
||||||
register('SET_SELECTION', (args) => api.setSelection(args));
|
register('SET_SELECTION', (args) => api.setSelection(args));
|
||||||
|
register('EXPORT_AUDIO', (args) => api.exportAudio(args));
|
||||||
register('SET_BPM', (args) => api.setBpm(args));
|
register('SET_BPM', (args) => api.setBpm(args));
|
||||||
register('SET_PLAYHEAD', (args) => api.setPlayhead(args));
|
register('SET_PLAYHEAD', (args) => api.setPlayhead(args));
|
||||||
register('SELECT_ITEM', (args) => api.selectItem(args));
|
register('SELECT_ITEM', (args) => api.selectItem(args));
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user