fix: sửa copilot không gửi AI provider
This commit is contained in:
+345
-108
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user