89c7237379
- SoundFontInspector (sf2utils) scans .sf2, generates full/condensed catalog - GET /api/v1/plugins/soundfonts/catalog with lazy init + cache invalidation - AI tool generate_multitrack_midi now requires soundfont_id/bank/program - Condensed catalog auto-injected into AI system prompt with bank rules - Server render: FluidSynth program_select uses bank/program + channel routing (drums→ch9) - VST3 pedalboard path inserts CC0 bank select + program change before notes - DecentSamplerManager loads .dspreset with CWD fix for relative sample paths - Pianobook render branch in render_engine.py - Client SonicSF: controllerChange, programChange, applyAITrackInstrument - Post-AI track creation applies instrument via applyAITrackInstrument - Background cache rescan on .sf2 upload, frontend re-fetches catalog - libcurl4 + VST3 dirs in Dockerfile
317 lines
18 KiB
JavaScript
317 lines
18 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: 'set_selection', description: 'Chọn vùng timeline', parameters: { type: 'object', properties: { start_bar: { type: 'number' }, end_bar: { type: 'number' }, start_time: { type: 'number' }, end_time: { type: 'number' }, length_bars: { type: 'number' } } }
|
|
}, {
|
|
name: 'cut_audio', description: 'Cắt audio, snap zero-crossing, tạo track mới', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, end_bar: { type: 'number' }, length_bars: { type: 'number' }, snap_silence: { type: 'boolean' }, new_track_name: { type: 'string' } } }
|
|
}, {
|
|
name: 'create_track', description: 'Tạo track mới', parameters: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string', enum: ['audio', 'midi'] } }, required: ['name'] }
|
|
}, {
|
|
name: 'delete_track', description: 'Xóa track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
|
|
}, {
|
|
name: 'rename_track', description: 'Đổi tên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, name: { type: 'string' } }, required: ['track_id', 'name'] }
|
|
}, {
|
|
name: 'add_clip', description: 'Thêm clip rỗng vào track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, duration_seconds: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' }, name: { type: 'string' } } }
|
|
}, {
|
|
name: 'remove_clip', description: 'Xóa clip khỏi track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, clip_id: { type: 'string' } }, required: ['clip_id'] }
|
|
}, {
|
|
name: 'set_track_volume', description: 'Chỉnh âm lượng dB', parameters: { type: 'object', properties: { track_id: { type: 'string' }, volume_db: { type: 'number' } }, required: ['volume_db'] }
|
|
}, {
|
|
name: 'set_track_pan', description: 'Chỉnh pan trái/phải', parameters: { type: 'object', properties: { track_id: { type: 'string' }, pan: { type: 'integer' } }, required: ['pan'] }
|
|
}, {
|
|
name: 'toggle_mute', description: 'Mute/unmute track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
|
|
}, {
|
|
name: 'toggle_solo', description: 'Solo/unsolo track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
|
|
}, {
|
|
name: 'set_bpm', description: 'Thay đổi BPM', parameters: { type: 'object', properties: { bpm: { type: 'number' } }, required: ['bpm'] }
|
|
}, {
|
|
name: 'set_playhead', description: 'Di chuyển playhead', parameters: { type: 'object', properties: { time: { type: 'number' }, bar: { type: 'number' } } }
|
|
}, {
|
|
name: 'add_marker', description: 'Thêm marker', parameters: { type: 'object', properties: { track_id: { type: 'string' }, time: { type: 'number' }, label: { type: 'string' } } }
|
|
}, {
|
|
name: 'process_audio_dsp', description: 'Xử lý DSP: normalize/invert/gain/pitch', parameters: { type: 'object', properties: { track_id: { type: 'string' }, action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'] }, params: { type: 'object' } }, required: ['track_id', 'action'] }
|
|
}, {
|
|
name: 'create_midi_item', description: 'Tạo MIDI item trên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['track_id', 'start_bar', 'length_bars'] }
|
|
}, {
|
|
name: 'modify_midi_notes', description: 'Sửa note MIDI trong item', parameters: { type: 'object', properties: { item_id: { type: 'string' }, notes: { type: 'array', items: { type: 'object', properties: { pitch: { type: 'string' }, start_time: { type: 'number' }, duration: { type: 'number' }, velocity: { type: 'integer', minimum: 0, maximum: 127 } }, required: ['pitch', 'start_time', 'duration'] } } }, required: ['item_id', 'notes'] }
|
|
}, {
|
|
name: 'select_item', description: 'Chọn clip/item theo tên', parameters: { type: 'object', properties: { track_id: { type: 'string' }, item_name: { type: 'string' }, select_all: { type: 'boolean' } } }
|
|
}, {
|
|
name: 'scan_track', description: 'Phân tích track: BPM, SR, kênh', parameters: { type: 'object', properties: { track_id: { type: 'string' }, set_tempo: { type: 'boolean' } } }
|
|
}, {
|
|
name: 'fade_in', description: 'Fade-in clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } }
|
|
}, {
|
|
name: 'export_audio', description: 'Xuất file WAV/MP3/OGG và tải về', parameters: { type: 'object', properties: { track_id: { type: 'string' }, format: { type: 'string', enum: ['wav', 'mp3', 'ogg'] }, sample_rate: { type: 'string', enum: ['22500', '44100'] }, bit_depth: { type: 'string', enum: ['8', '16', '24'] }, quality: { type: 'string', enum: ['44khz', 'lossless'] }, channels: { type: 'string', enum: ['mono', 'stereo'] }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['format'] }
|
|
}, {
|
|
name: 'fade_out', description: 'Fade-out clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } }
|
|
}, {
|
|
name: 'generate_multitrack_midi',
|
|
description: 'Generates multi-track MIDI data along with SoundFont Program configurations for each track.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
composition_title: { type: 'string', description: 'Title of the musical piece (e.g., Epic Orchestra Intro 8-Bars)' },
|
|
bpm: { type: 'integer' },
|
|
total_bars: { type: 'integer' },
|
|
tracks: {
|
|
type: 'array',
|
|
description: 'Array of instrument tracks with MIDI notes and SoundFont instrument selection',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
track_name: { type: 'string', description: 'Track name (e.g., String Ensemble, Epic Brass, Taiko Drums)' },
|
|
instrument_type: { type: 'string', enum: ['STRINGS', 'BRASS', 'SYNTH', 'PERCUSSION', 'DRUMS'] },
|
|
soundfont_id: { type: 'string', description: "ID of the SoundFont to use (e.g. 'generaluser_gs')" },
|
|
soundfont_bank: { type: 'integer', default: 0, description: 'MIDI Bank code: 0 for melodic instruments, 128 for Drums/Percussion' },
|
|
soundfont_program: { type: 'integer', description: 'MIDI Program Number 0-127 matching the instrument name in the SoundFont catalog' },
|
|
notes: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
pitch: { type: 'integer', description: 'MIDI note pitch from 0 to 127 (e.g., C4 = 60, C3 = 48)' },
|
|
start_beat: { type: 'number', description: 'Note start position in beats (from 0.0 to total_bars * 4.0)' },
|
|
duration_beats: { type: 'number', description: 'Note length in beats (e.g., Quarter note = 1.0, Eighth note = 0.5)' },
|
|
velocity: { type: 'number', description: 'Keypress velocity intensity from 0.0 to 1.0' }
|
|
},
|
|
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
|
|
}
|
|
}
|
|
},
|
|
required: ['track_name', 'instrument_type', 'soundfont_id', 'soundfont_bank', 'soundfont_program', 'notes']
|
|
}
|
|
}
|
|
},
|
|
required: ['composition_title', 'bpm', 'total_bars', 'tracks']
|
|
}
|
|
}];
|
|
|
|
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 buildCatalogPromptSection() {
|
|
const catalog = window.__soundfontCatalog;
|
|
if (!catalog || !catalog.condensed_catalog) return '';
|
|
const lines = [];
|
|
for (const [sfId, info] of Object.entries(catalog.condensed_catalog)) {
|
|
lines.push(`SoundFont ID: '${sfId}' (File: ${info.filename}):`);
|
|
for (const inst of info.instruments || []) {
|
|
lines.push(` - ${inst.name}: bank=${inst.bank}, program=${inst.program}`);
|
|
}
|
|
}
|
|
if (lines.length === 0) return '';
|
|
return `\n=== SOUNDFONT INSTRUMENT CATALOG ===\nYou have the following SoundFont instruments available on the system:\n${lines.join('\n')}\n\nMANDATORY RULES WHEN CREATING TRACKS WITH generate_multitrack_midi:\n1. You MUST look up the catalog above and fill in the correct soundfont_id, soundfont_bank, and soundfont_program for each track.\n2. Melodic instruments (Piano, Strings, Brass, etc.) MUST use soundfont_bank=0.\n3. Drums and Percussion MUST use soundfont_bank=128.\n4. Example: For \"Brass horns\", use soundfont_id="generaluser_gs", soundfont_bank=0, soundfont_program=56.\n5. Example: For \"Drum kit\", use soundfont_id="generaluser_gs", soundfont_bank=128, soundfont_program=0.\n`;
|
|
}
|
|
|
|
function buildUserMessage(prompt, context, systemInstruction = '') {
|
|
const contextStr = JSON.stringify(context, null, 2);
|
|
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
|
const catalogSection = buildCatalogPromptSection();
|
|
return [
|
|
{ role: 'system', content: `Bạn là trợ lý điều khiển DAW chuyên nghiệp.
|
|
Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng.
|
|
${systemInstruction ? `\nHướng dẫn tạo nhạc đặc biệt từ Preset:\n${systemInstruction}\n` : ''}
|
|
${catalogSection}
|
|
QUAN TRỌNG:
|
|
1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat.
|
|
2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về.
|
|
3. Khi người dùng yêu cầu chọn và cắt/sao chép/copy một đoạn nhạc từ track cũ để tạo đoạn nhạc mới (bằng lệnh 'cut_audio'), và sau đó yêu cầu xử lý tiếp đoạn nhạc mới tạo đó (ví dụ: 'sau đó fade in đoạn đó', 'chỉnh âm lượng đoạn đó', 'xuất mp3 đoạn đó'...), thì tất cả các lệnh xử lý tiếp theo này (như 'fade_in', 'export_audio', 'set_track_volume') PHẢI để trống tham số 'track_id' (hoặc truyền null/không truyền) để hệ thống tự động áp dụng lên track mới vừa được tạo ra. KHÔNG ĐƯỢC dùng 'track_id' của track gốc ban đầu cho các lệnh xử lý phía sau.
|
|
Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó fade in clip đó 3s, xuất ra mp3" -> Bạn phải trả về đồng thời 3 cuộc gọi hàm theo thứ tự:
|
|
- cut_audio({"track_id": "1", "start_bar": 4, "end_bar": 12})
|
|
- fade_in({"duration_seconds": 3}) (không truyền track_id)
|
|
- export_audio({"format": "mp3"}) (không truyền track_id)
|
|
4. Bar 0 đại diện cho bar đầu tiên trên timeline.` },
|
|
{ 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, systemInstruction }) {
|
|
const messages = buildUserMessage(prompt, dawContext, systemInstruction);
|
|
const toolList = tools || DEFAULT_TOOLS;
|
|
|
|
const completion = await callLLM({
|
|
provider,
|
|
model,
|
|
apiKey,
|
|
baseUrl,
|
|
messages,
|
|
tools: toolList,
|
|
toolChoice: 'auto'
|
|
});
|
|
|
|
if (completion && completion.error) {
|
|
const errMsg = completion.error.message || completion.error.code || JSON.stringify(completion.error);
|
|
throw new Error(`AI Provider error: ${errMsg}`);
|
|
}
|
|
|
|
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;
|