425 lines
16 KiB
JavaScript
425 lines
16 KiB
JavaScript
// SonicForge Studio - AI Gateway & Function Routing
|
|
// LLM Gateway with Function Calling / Structured Outputs (28_AI_PANEL.md §2)
|
|
|
|
const AIGateway = (function() {
|
|
const DEFAULT_TOOLS = [{
|
|
name: 'create_track',
|
|
description: 'Tạo một track âm thanh mới trong dự án',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
name: { type: 'string', description: 'Tên cho track mới (VD: Beat, Vocal, Guitar)' },
|
|
type: { type: 'string', enum: ['audio', 'midi'], description: 'Loại track' }
|
|
},
|
|
required: ['name']
|
|
}
|
|
}, {
|
|
name: 'delete_track',
|
|
description: 'Xóa một track khỏi dự án',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track cần xóa. Nếu không có thì xóa track đang chọn.' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'add_clip',
|
|
description: 'Thêm một clip âm thanh rỗng vào track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track đích' },
|
|
start_time: { type: 'number', description: 'Vị trí bắt đầu (giây)' },
|
|
duration_seconds: { type: 'number', description: 'Độ dài clip (giây)' },
|
|
start_bar: { type: 'number', description: 'Vị trí bắt đầu (bar). Bar 0 = bar đầu tiên. Dùng thay cho start_time.' },
|
|
length_bars: { type: 'number', description: 'Độ dài (bar). Dùng thay cho duration_seconds.' },
|
|
name: { type: 'string', description: 'Tên clip' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'remove_clip',
|
|
description: 'Xóa một clip khỏi track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track' },
|
|
clip_id: { type: 'string', description: 'ID của clip cần xóa' }
|
|
},
|
|
required: ['clip_id']
|
|
}
|
|
}, {
|
|
name: 'set_track_volume',
|
|
description: 'Điều chỉnh âm lượng của track (dB)',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track' },
|
|
volume_db: { type: 'number', description: 'Âm lượng tính bằng dB (VD: -6, 0, +3)' }
|
|
},
|
|
required: ['volume_db']
|
|
}
|
|
}, {
|
|
name: 'set_track_pan',
|
|
description: 'Điều chỉnh cân bằng trái/phải (pan) của track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track' },
|
|
pan: { type: 'integer', description: 'Pan value: -100 (trái), 0 (trung tâm), 100 (phải)' }
|
|
},
|
|
required: ['pan']
|
|
}
|
|
}, {
|
|
name: 'toggle_mute',
|
|
description: 'Bật/tắt mute (tắt tiếng) của track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track cần mute/unmute' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'toggle_solo',
|
|
description: 'Bật/tắt solo (chỉ nghe track này) của track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track cần solo/unsolo' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'rename_track',
|
|
description: 'Đổi tên của một track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track cần đổi tên' },
|
|
name: { type: 'string', description: 'Tên mới cho track' }
|
|
},
|
|
required: ['track_id', 'name']
|
|
}
|
|
}, {
|
|
name: 'set_bpm',
|
|
description: 'Thay đổi tempo (BPM) của dự án',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
bpm: { type: 'number', description: 'Tempo mới tính bằng BPM (VD: 120, 128, 140)' }
|
|
},
|
|
required: ['bpm']
|
|
}
|
|
}, {
|
|
name: 'set_playhead',
|
|
description: 'Di chuyển playhead (con trỏ phát) đến vị trí chỉ định',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
time: { type: 'number', description: 'Vị trí thời gian tính bằng giây' },
|
|
bar: { type: 'number', description: 'Vị trí bar (0 = bar đầu tiên). Dùng thay cho time.' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'add_marker',
|
|
description: 'Thêm một marker (đánh dấu) vào track tại vị trí chỉ định',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track' },
|
|
time: { type: 'number', description: 'Vị trí thời gian (giây). Mặc định là vị trí playhead hiện tại.' },
|
|
label: { type: 'string', description: 'Nhãn cho marker' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'process_audio_dsp',
|
|
description: 'Xử lý hiệu ứng âm thanh DSP cho track (chuẩn hóa, đảo phase, gain, pitch shift)',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track âm thanh' },
|
|
action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'], description: 'Loại xử lý DSP' },
|
|
params: { type: 'object', description: 'Tham số bổ sung: gain_db cho gain, semitones cho pitch_shift' }
|
|
},
|
|
required: ['track_id', 'action']
|
|
}
|
|
}, {
|
|
name: 'create_midi_item',
|
|
description: 'Thêm một MIDI item/clip vào track',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track đích' },
|
|
start_bar: { type: 'number', description: 'Vị trí bắt đầu (tính bằng bar)' },
|
|
length_bars: { type: 'number', description: 'Độ dài item (tính bằng bar)' }
|
|
},
|
|
required: ['track_id', 'start_bar', 'length_bars']
|
|
}
|
|
}, {
|
|
name: 'set_selection',
|
|
description: 'Chọn một vùng trên timeline (selection range). Dùng để xác định khoảng thời gian trước khi gọi các lệnh khác.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
start_bar: { type: 'number', description: 'Bar bắt đầu (0 = bar đầu tiên của project)' },
|
|
end_bar: { type: 'number', description: 'Bar kết thúc' },
|
|
start_time: { type: 'number', description: 'Thời gian bắt đầu (giây). Dùng thay cho start_bar.' },
|
|
end_time: { type: 'number', description: 'Thời gian kết thúc (giây). Dùng thay cho end_bar.' },
|
|
length_bars: { type: 'number', description: 'Độ dài vùng chọn (bar). Dùng cùng start_bar thay cho end_bar.' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'select_item',
|
|
description: 'Chọn một item/clip/sample cụ thể trên track. Nếu không có track_id và item_name thì chọn toàn bộ timeline.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track. Nếu không có thì dùng track đang chọn.' },
|
|
item_name: { type: 'string', description: 'Tên của clip/sample cần chọn. VD: "Cut_Cartoon Capers Loop.mp3". Nếu không có thì chọn toàn bộ track.' },
|
|
select_all: { type: 'boolean', description: 'Chọn toàn bộ timeline (bỏ qua track_id và item_name)' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'cut_audio',
|
|
description: 'Cắt đoạn audio đang được chọn từ track nguồn, snap zero-crossing, tạo track mới chứa đoạn cắt',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track nguồn' },
|
|
start_time: { type: 'number', description: 'Vị trí bắt đầu cắt (giây). Mặc định: dùng selection hiện tại.' },
|
|
end_time: { type: 'number', description: 'Vị trí kết thúc cắt (giây). Mặc định: dùng selection hiện tại.' },
|
|
start_bar: { type: 'number', description: 'Bar bắt đầu cắt (0 = bar đầu). Dùng thay cho start_time.' },
|
|
length_bars: { type: 'number', description: 'Độ dài cắt (bar). Dùng cùng start_bar.' },
|
|
snap_silence: { type: 'boolean', description: 'Tự động snap vào điểm silence/gần silence gần nhất ở hai đầu (mặc định: true)' },
|
|
new_track_name: { type: 'string', description: 'Tên cho track mới. Mặc định: "Cut_<tên_track_gốc>"' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'scan_track',
|
|
description: 'Quét và phân tích track âm thanh: phát hiện BPM (tempo) và tự động cập nhật tempo hệ thống, sample rate, số kênh (mono/stereo), duration',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
track_id: { type: 'string', description: 'ID của track cần quét' },
|
|
set_tempo: { type: 'boolean', description: 'Tự động cập nhật tempo hệ thống (mặc định: true)' }
|
|
}
|
|
}
|
|
}, {
|
|
name: 'modify_midi_notes',
|
|
description: 'Thêm, chỉnh sửa hoặc xóa các note MIDI trong item',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
item_id: { type: 'string', description: 'ID của MIDI item' },
|
|
notes: {
|
|
type: 'array',
|
|
description: 'Danh sách các note MIDI',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
pitch: { type: 'string', description: 'VD: C4, D#3, F5' },
|
|
start_time: { type: 'number', description: 'Thời điểm bắt đầu (bar hoặc giây)' },
|
|
duration: { type: 'number', description: 'Độ dài note' },
|
|
velocity: { type: 'integer', minimum: 0, maximum: 127, description: 'Độ mạnh 0-127' }
|
|
},
|
|
required: ['pitch', 'start_time', 'duration']
|
|
}
|
|
}
|
|
},
|
|
required: ['item_id', 'notes']
|
|
}
|
|
}];
|
|
|
|
function parseOrigin(urlStr) {
|
|
try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; }
|
|
}
|
|
|
|
function isLocalhost(urlStr) {
|
|
try {
|
|
const u = new URL(urlStr);
|
|
return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0' || u.hostname === '::1';
|
|
} catch (_) { return false; }
|
|
}
|
|
|
|
async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) {
|
|
const base = baseUrl.replace(/\/$/, '');
|
|
const url = `${base}/chat/completions`;
|
|
const origin = window.location.origin;
|
|
const urlOrigin = parseOrigin(url);
|
|
const appOrigin = parseOrigin(origin);
|
|
const sameOrigin = urlOrigin === appOrigin;
|
|
const targetIsLocal = isLocalhost(url);
|
|
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {})
|
|
};
|
|
|
|
const body = {
|
|
model,
|
|
messages,
|
|
stream: false,
|
|
...(tools && tools.length > 0 ? { tools: tools.map(t => ({ type: 'function', function: t })) } : {}),
|
|
...(toolChoice ? { tool_choice: toolChoice } : {})
|
|
};
|
|
|
|
let response;
|
|
|
|
if (sameOrigin) {
|
|
response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body)
|
|
});
|
|
} else if (targetIsLocal && !isLocalhost(origin)) {
|
|
throw new Error(`AI provider local (${url}) không khả dụng từ domain từ xa (${origin}).\nHãy dùng provider từ xa (OpenAI, Anthropic...) hoặc dùng CORS plugin trình duyệt.`);
|
|
} else {
|
|
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, headers, body })
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errText = await response.text();
|
|
let detail = errText;
|
|
try { const j = JSON.parse(errText); if (j.detail) detail = j.detail; } catch (_) {}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
function extractFunctionCalls(completion) {
|
|
const calls = [];
|
|
const choice = completion.choices && completion.choices[0];
|
|
if (!choice) return calls;
|
|
|
|
const msg = choice.message;
|
|
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
for (const tc of msg.tool_calls) {
|
|
if (tc.type === 'function' && tc.function) {
|
|
let args = {};
|
|
try { args = JSON.parse(tc.function.arguments || '{}'); } catch (e) { args = { raw: tc.function.arguments }; }
|
|
calls.push({
|
|
id: tc.id,
|
|
name: tc.function.name,
|
|
arguments: args
|
|
});
|
|
}
|
|
}
|
|
} else if (msg.function_call) {
|
|
let args = {};
|
|
try { args = JSON.parse(msg.function_call.arguments || '{}'); } catch (e) { args = { raw: msg.function_call.arguments }; }
|
|
calls.push({
|
|
id: 'call_' + Date.now(),
|
|
name: msg.function_call.name,
|
|
arguments: args
|
|
});
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
function buildUserMessage(prompt, context) {
|
|
const contextStr = JSON.stringify(context, null, 2);
|
|
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
|
return [
|
|
{ role: 'system', content: `Bạn là trợ lý AI cho DAW (SonicForge Studio). Các lệnh DAW có sẵn:\n${toolNames}\n\nQUAN TRỌNG: Bar được đánh số từ 0 (bar 0 = bar đầu tiên). VD: bar 0-3 = 4 bar đầu tiên.\nPhân tích yêu cầu và trả về FUNCTION CALLS. Có thể gọi nhiều function cùng lúc.` },
|
|
{ role: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` }
|
|
];
|
|
}
|
|
|
|
function buildAIPromptContext(dawState) {
|
|
const tracks = (dawState.tracks || []).map(t => {
|
|
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, name: t.name, startTime: t.startTime || 0, duration: t.buffer.duration }] : []);
|
|
return {
|
|
id: t.id,
|
|
name: t.name,
|
|
type: t.buffer ? 'audio' : 'empty',
|
|
hasBuffer: !!t.buffer,
|
|
muted: t.muted,
|
|
solo: t.solo,
|
|
volumeDb: t.volumeDb ?? 0,
|
|
pan: t.pan ?? 0,
|
|
clips: clips.map(c => ({ id: c.id, name: c.name, startTime: parseFloat((c.startTime || 0).toFixed(3)), duration: parseFloat((c.buffer ? c.buffer.duration : 0).toFixed(3)) }))
|
|
};
|
|
});
|
|
return {
|
|
tempo: parseInt(dawState.bpm || '120'),
|
|
timeSignature: '4/4',
|
|
selectedTrackId: dawState.selectedTrackId || null,
|
|
playheadPosition: parseFloat((dawState.currentTime || 0).toFixed(3)),
|
|
selection: (dawState.selLeft !== null && dawState.selRight !== null && dawState.selRight > dawState.selLeft) ? {
|
|
start: parseFloat(dawState.selLeft.toFixed(3)),
|
|
end: parseFloat(dawState.selRight.toFixed(3)),
|
|
length: parseFloat((dawState.selRight - dawState.selLeft).toFixed(3))
|
|
} : null,
|
|
tracks
|
|
};
|
|
}
|
|
|
|
async function executeAIPrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools }) {
|
|
const messages = buildUserMessage(prompt, dawContext);
|
|
const toolList = tools || DEFAULT_TOOLS;
|
|
|
|
const completion = await callLLM({
|
|
provider,
|
|
model,
|
|
apiKey,
|
|
baseUrl,
|
|
messages,
|
|
tools: toolList,
|
|
toolChoice: 'auto'
|
|
});
|
|
|
|
const functionCalls = extractFunctionCalls(completion);
|
|
const textResponse = completion.choices && completion.choices[0] && completion.choices[0].message && completion.choices[0].message.content
|
|
? completion.choices[0].message.content
|
|
: '';
|
|
|
|
return {
|
|
functionCalls,
|
|
textResponse,
|
|
raw: completion
|
|
};
|
|
}
|
|
|
|
async function createMidiItem(args) {
|
|
return fetch('/api/audio_editor', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'add_midi', ...args })
|
|
}).then(r => r.json());
|
|
}
|
|
|
|
async function modifyMidiNotes(args) {
|
|
return fetch('/api/audio_editor', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'modify_midi_notes', ...args })
|
|
}).then(r => r.json());
|
|
}
|
|
|
|
async function processAIDSP(args) {
|
|
return fetch('/api/ai_dsp_engine', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'process_ai_dsp', ...args })
|
|
}).then(r => r.json());
|
|
}
|
|
|
|
return {
|
|
DEFAULT_TOOLS,
|
|
callLLM,
|
|
extractFunctionCalls,
|
|
buildUserMessage,
|
|
buildAIPromptContext,
|
|
executeAIPrompt,
|
|
createMidiItem,
|
|
modifyMidiNotes,
|
|
processAIDSP
|
|
};
|
|
})();
|
|
|
|
window.executeAIPrompt = AIGateway.executeAIPrompt;
|
|
window.AIGateway = AIGateway;
|