// 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: '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_"' } } } }, { 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;