const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const zlib = require('zlib'); const { XMLParser, XMLBuilder } = require('fast-xml-parser'); const fetch = require('node-fetch'); const DATA_DIR = path.resolve(__dirname, '..'); const WORKSPACE_DIR = process.env.ALS_WORKSPACE_DIR ? path.resolve(process.env.ALS_WORKSPACE_DIR) : DATA_DIR; const LIBRARY_JSON = path.join(DATA_DIR, 'als-library.json'); const LIBRARY_DIR = path.join(DATA_DIR, 'library'); const SOURCES_DIR = path.join(WORKSPACE_DIR, 'sources'); const GENERATED_DIR = path.join(WORKSPACE_DIR, 'generated'); const UPLOAD_URL = process.env.CHATBOT_UPLOAD_URL || 'http://localhost:1880/als/upload'; const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false }); const builder = new XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '', suppressEmptyNode: true }); function ensureDirs() { fs.mkdirSync(LIBRARY_DIR, { recursive: true }); fs.mkdirSync(SOURCES_DIR, { recursive: true }); fs.mkdirSync(GENERATED_DIR, { recursive: true }); } function readLibrary() { try { const raw = fs.readFileSync(LIBRARY_JSON, 'utf8'); return raw ? JSON.parse(raw) : []; } catch (err) { if (err.code === 'ENOENT') { return []; } throw err; } } function scanSources() { const result = []; if (!fs.existsSync(SOURCES_DIR)) { return result; } function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.')) { continue; } const absolute = path.join(dir, entry.name); const relative = path.relative(SOURCES_DIR, absolute); if (entry.isDirectory()) { walk(absolute); } else { result.push(relative); } } } walk(SOURCES_DIR); return result; } function slugify(text) { return text .toLowerCase() .replace(/[^a-z0-9]+/gi, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'als'; } function scoreEntry(entry, promptTokens) { let score = 0; const haystack = [ entry.projectName, entry.meta?.creator, ...(entry.tracks || []).map((t) => t.name) ] .join(' ') .toLowerCase(); for (const token of promptTokens) { if (haystack.includes(token)) { score += 2; } } score += (entry.tracks || []).length; score += entry.stats?.devices || 0; return score; } function extractJson(text) { if (!text) { return null; } try { return JSON.parse(text); } catch (_) { const match = text.match(/\{[\s\S]*\}/); if (match) { try { return JSON.parse(match[0]); } catch (err) { return null; } } } return null; } function anthConfig() { const base = (process.env.ANTHROPIC_BASE_URL || '').trim() || (process.env.ANTHROPIC_FALLBACK_BASE_URL || '').trim(); const token = (process.env.ANTHROPIC_AUTH_TOKEN || '').trim() || (process.env.ANTHROPIC_FALLBACK_TOKEN || '').trim(); if (!base || !token) { return null; } return { endpoint: `${base.replace(/\/$/, '')}/v1/messages`, token }; } async function callAnthropic(systemText, userText, maxTokens = 800) { const cfg = anthConfig(); if (!cfg) { return null; } const payload = { model: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022', max_tokens: maxTokens, temperature: 0.3, system: systemText, messages: [ { role: 'user', content: [{ type: 'text', text: userText }] } ] }; try { const res = await fetch(cfg.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': cfg.token, 'anthropic-version': '2023-06-01' }, body: JSON.stringify(payload), timeout: Number(process.env.API_TIMEOUT_MS) || 60000 }); if (!res.ok) { throw new Error(await res.text()); } const data = await res.json(); return data?.content?.[0]?.text || ''; } catch (err) { console.warn('[alsGenerator] Anthropic error:', err.message); return null; } } async function planWithAI(prompt, library, sources) { const cfg = anthConfig(); if (!cfg) { return null; } const contextEntries = library .slice(0, 3) .map((entry) => ({ projectName: entry.projectName, hash: entry.hash, tempo: entry.liveSet?.tempo, tracks: (entry.tracks || []).slice(0, 5).map((t) => t.name) })); const text = await callAnthropic( 'Eres un generador de sesiones Ableton Live (.als). Devuelves un JSON con projectName, templateHash, tempo, trackNames (array) y notes. Usa solo los hashes ofrecidos.', `Prompt del usuario: ${prompt}\nPlantillas disponibles: ${JSON.stringify( contextEntries, null, 2 )}\nSources disponibles (${sources.length}): ${sources.join(', ') || 'ninguna'}\nRespuesta esperada (JSON): {"projectName":"","templateHash":"","tempo":120,"trackNames":["..."],"notes":"..."}` ); return extractJson(text); } function setNameNode(nameNode, value) { if (!nameNode) { return; } if (nameNode.EffectiveName) { nameNode.EffectiveName.Value = value; } if (nameNode.UserName) { nameNode.UserName.Value = value; } if (nameNode.Value) { nameNode.Value = value; } } function collectTracks(liveSet) { const tracksNode = liveSet?.Tracks || {}; const keys = ['AudioTrack', 'MidiTrack', 'GroupTrack']; const result = []; for (const key of keys) { const arr = Array.isArray(tracksNode[key]) ? tracksNode[key] : tracksNode[key] ? [tracksNode[key]] : []; for (const track of arr) { result.push(track); } } return result; } function applyPlanToLiveSet(xmlObj, plan, prompt, sources) { const ableton = xmlObj.Ableton || (xmlObj.Ableton = {}); const liveSet = ableton.LiveSet || (ableton.LiveSet = {}); liveSet.Name = liveSet.Name || {}; setNameNode(liveSet.Name, plan.projectName); const annotationLines = [ `Prompt: ${prompt}`, `Notas: ${plan.notes || 'generado automáticamente'}`, `Sources (${sources.length}): ${sources.join(', ') || 'N/A'}` ]; liveSet.Annotation = liveSet.Annotation || {}; liveSet.Annotation.Value = annotationLines.join('\\n'); const tempoValue = plan.tempo || plan.targetTempo || liveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual?.Value; if (tempoValue && liveSet.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual) { liveSet.MainTrack.DeviceChain.Mixer.Tempo.Manual.Value = tempoValue; } const trackNames = plan.trackNames || []; const tracks = collectTracks(liveSet); trackNames.forEach((name, idx) => { if (tracks[idx]) { tracks[idx].Name = tracks[idx].Name || {}; setNameNode(tracks[idx].Name, name); } }); } async function registerFile(filePath) { try { const buffer = fs.readFileSync(filePath); const dataUrl = `data:application/octet-stream;base64,${buffer.toString('base64')}`; const res = await fetch(UPLOAD_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ filename: path.basename(filePath), size: buffer.length, data: dataUrl }), timeout: Number(process.env.API_TIMEOUT_MS) || 60000 }); const text = await res.text(); let payload = text; try { payload = JSON.parse(text); } catch (_) { // noop } if (!res.ok) { throw new Error(typeof payload === 'string' ? payload : payload.error || 'Error desconocido'); } return payload; } catch (err) { console.warn('[alsGenerator] No se pudo registrar el archivo generado:', err.message); return null; } } async function generateFromPrompt(prompt, options = {}) { ensureDirs(); const library = readLibrary(); if (!library.length) { throw new Error('No hay ALS en la biblioteca. Sube al menos uno antes de generar.'); } const sources = scanSources(); const tokens = prompt .toLowerCase() .split(/[^a-z0-9]+/) .filter(Boolean); const ranked = [...library].sort( (a, b) => scoreEntry(b, tokens) - scoreEntry(a, tokens) ); const fallbackEntry = ranked[0]; let plan = await planWithAI(prompt, ranked.slice(0, 5), sources); if (!plan) { plan = { projectName: `AI ${prompt}`.trim().slice(0, 60), templateHash: fallbackEntry.hash, tempo: fallbackEntry.liveSet?.tempo || 120, trackNames: (fallbackEntry.tracks || []).map((t, idx) => `${tokens[idx] || 'Track'} ${idx + 1}`), notes: 'Plan generado con heurística local.' }; } if (!plan.templateHash || !library.find((e) => e.hash === plan.templateHash)) { plan.templateHash = fallbackEntry.hash; } plan.projectName = plan.projectName || `AI-${slugify(prompt)}-${plan.templateHash.slice(0, 4)}`; const templatePath = path.join(LIBRARY_DIR, `${plan.templateHash}.als`); if (!fs.existsSync(templatePath)) { throw new Error(`No se encontró la plantilla ${plan.templateHash}.`); } const templateBuffer = fs.readFileSync(templatePath); const xmlString = zlib.gunzipSync(templateBuffer).toString('utf8'); const xmlObj = parser.parse(xmlString); applyPlanToLiveSet(xmlObj, plan, prompt, sources); const newXmlString = builder.build(xmlObj); const newBuffer = zlib.gzipSync(Buffer.from(newXmlString, 'utf8')); const slug = `${slugify(plan.projectName)}-${Date.now()}`; const outputPath = path.join(GENERATED_DIR, `${slug}.als`); fs.writeFileSync(outputPath, newBuffer); let registration = null; if (options.register !== false) { registration = await registerFile(outputPath); } return { prompt, plan, outputPath, registered: Boolean(registration), registration }; } async function conversationalReply(prompt, library, sources) { const context = { prompt, projects: library.slice(-5).map((entry) => ({ projectName: entry.projectName, hash: entry.hash, tempo: entry.liveSet?.tempo })), sources: sources.slice(0, 20) }; const text = await callAnthropic( 'Actúa como un productor musical amigable que ayuda a planear nuevos proyectos de Ableton Live. Responde en español latino, máximo 3 frases, incluye sugerencias creativas y cuándo usar \"generame un als ...\".', `Usuario: ${prompt}\nContexto: ${JSON.stringify(context, null, 2)}` ); if (text) { return text.trim(); } if (!library.length) { return '¡Hola! Aún no tengo proyectos para tomar como referencia. Sube un .als y cuéntame qué estilo buscas; luego pide "generame un als ..." y prepararé una sesión.'; } const recent = library.slice(-1)[0]; const srcText = sources && sources.length ? `También veo ${sources.length} archivos en data/sources/ (ej: ${sources.slice(0, 3).join(', ')}). ` : ''; return `Hola, tengo ${library.length} proyectos guardados y el más reciente es "${recent.projectName}" (${recent.liveSet?.tempo || 'N/D'} BPM). ${srcText}Cuéntame la vibra y cuando digas "generame un als ..." lo construyo.`; } module.exports = { generateFromPrompt, listLibrary: () => { ensureDirs(); return readLibrary(); }, listSources: () => { ensureDirs(); return scanSources(); }, chatReply: conversationalReply };