Files
SonicForgeStudio/app/static/js/services/aiGateway.js
T

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;