diff --git a/.env.example b/.env.example index e0a9772..2671d33 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,11 @@ NODE_RED_FLOW_CREDENTIAL_SECRET=change-this-secret GITEA_TOKEN=put-your-token-here GITEA_USER=your-username GITEA_URL=https://gitea.cbcren.online/ +ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic +ANTHROPIC_AUTH_TOKEN=replace-with-your-token +ANTHROPIC_FALLBACK_BASE_URL=https://api.minimax.io/anthropic +ANTHROPIC_FALLBACK_TOKEN=replace-with-your-secondary-token +ANTHROPIC_MODEL=MiniMax-M2 +API_TIMEOUT_MS=300000 +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +CHATBOT_UPLOAD_URL=http://localhost:1880/als/upload diff --git a/.gitignore b/.gitignore index cb34e25..03bb973 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ data/flows_cred.json data/*_cred.json als/ data/.flows* +data/library/ +!data/library/.gitkeep +data/sources/* +!data/sources/.gitkeep +data/generated/* +!data/generated/.gitkeep diff --git a/README.md b/README.md index 11ec0c2..52d43cb 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,21 @@ Se añadió un flow en `data/flows.json` que usa Node-RED Dashboard para mostrar - Si intentas subir el mismo `.als`, el flujo detecta el hash duplicado, responde `409` y el dashboard avisa sin sobrescribir la biblioteca. - Para consultar el inventario (por ejemplo, desde una IA) usa `GET /als/library`; el endpoint devuelve el arreglo completo de proyectos almacenados. - Como el archivo vive dentro de `/data`, puedes versionarlo o respaldarlo fácilmente según tu flujo de trabajo. + +### Chatbot y generación de nuevos `.als` + +- Coloca tus stems/loops en `data/sources/` (el contenedor los comparte con Node-RED). Los `.als` creados se guardan en `data/generated/` y las plantillas descomprimidas viven en `data/library/`. +- Configura en `.env` las credenciales de MiniMax/GLM (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, etc.). Si la IA falla, el generador recurre a heurísticas locales con las plantillas existentes. +- Nuevo endpoint `POST /als/chat`: + ```json + { "prompt": "generame un als de reggaeton 2001" } + ``` + Devuelve el resumen del proyecto creado (nombre, hash y ruta del archivo). En el dashboard aparece la tarjeta **Chatbot ALS** para conversar visualmente. +- Internamente se usa `data/lib/alsGenerator.js`: el bot elige una plantilla (`als-library.json`), edita el XML del `.als` (nombre del proyecto, tempo, anotaciones con los sources) y registra el resultado llamando otra vez a `/als/upload` para mantener la deduplicación. +- También puedes usarlo desde terminal: + ```bash + set -a && source .env + node scripts/chatbot.js # modo interactivo + node scripts/generate-als.js "generame un als tribal" + ``` + Ambos comandos generan el archivo dentro de `data/generated/` y lo añaden automáticamente a la biblioteca. diff --git a/data/als-library.json b/data/als-library.json index ae1444e..51d0922 100644 --- a/data/als-library.json +++ b/data/als-library.json @@ -168,6 +168,351 @@ "C:/VST2/Fabfilter/FabFilter Pro-C 2.dll" ] }, - "storedAt": "2025-12-01T02:46:38.916Z" + "storedAt": "2025-12-01T03:01:16.790Z", + "filePath": "library/9dbed77293415a951c43101ef30873d4.als" + }, + { + "hash": "13a5d5ab84b6dead00d8eb01c3bfa194", + "projectName": "ai-generame-un-als-de-reggaeton-2001-1764558136687.als", + "meta": { + "creator": "Ableton Live 12.2", + "version": "5.12.0_12203", + "revision": "1c7a2c5dacd710ba28150f2c1534c22b1c158263", + "fileName": "ai-generame-un-als-de-reggaeton-2001-1764558136687.als", + "sizeBytes": 6665479, + "sizeHuman": "6.4 MB" + }, + "liveSet": { + "tempo": 124, + "loopLengthBeats": 272, + "durationSeconds": 131.61290322580646, + "durationHuman": "2:12", + "loopStart": 0, + "scenes": [] + }, + "tracks": [ + { + "name": "generame 1", + "type": "Audio", + "deviceCount": 3, + "color": "22" + }, + { + "name": "un 2", + "type": "Audio", + "deviceCount": 0, + "color": "22" + }, + { + "name": "als 3", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "de 4", + "type": "Audio", + "deviceCount": 2, + "color": "22" + }, + { + "name": "reggaeton 5", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "2001 6", + "type": "MIDI", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Track 7", + "type": "MIDI", + "deviceCount": 4, + "color": "22" + }, + { + "name": "Track 8", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 9", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 10", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 11", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 12", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 13", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 14", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 15", + "type": "MIDI", + "deviceCount": 5, + "color": "22" + }, + { + "name": "Track 16", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 17", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Track 18", + "type": "Grupo", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 19", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + } + ], + "stats": { + "audio": 5, + "midi": 11, + "group": 3, + "totalTracks": 19, + "devices": 42, + "scenes": 0, + "samples": 8 + }, + "samples": { + "total": 8, + "relative": [ + "Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "Samples/Imported/Clap Fx.wav", + "Samples/Imported/Clap.wav", + "Swing and Groove/Swing/Swing 16-99.agr" + ], + "absolute": [ + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "C:/VST2/Proximity-x64.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "C:/Users/novik/OneDrive/Documenten/Ableton/User Library/Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "C:/VST2/Fabfilter/FabFilter Pro-Q 4.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap Fx.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap.wav", + "C:/VST2/Fabfilter/FabFilter Pro-C 2.dll" + ] + }, + "storedAt": "2025-12-01T03:02:18.543Z", + "filePath": "library/13a5d5ab84b6dead00d8eb01c3bfa194.als" + }, + { + "hash": "38427f49e8ffcfba20969319b9dda2f7", + "projectName": "ai-generame-un-als-tribal-1764558155174.als", + "meta": { + "creator": "Ableton Live 12.2", + "version": "5.12.0_12203", + "revision": "1c7a2c5dacd710ba28150f2c1534c22b1c158263", + "fileName": "ai-generame-un-als-tribal-1764558155174.als", + "sizeBytes": 6358425, + "sizeHuman": "6.1 MB" + }, + "liveSet": { + "tempo": 124, + "loopLengthBeats": 272, + "durationSeconds": 131.61290322580646, + "durationHuman": "2:12", + "loopStart": 0, + "scenes": [] + }, + "tracks": [ + { + "name": "generame 1", + "type": "Audio", + "deviceCount": 3, + "color": "22" + }, + { + "name": "un 2", + "type": "Audio", + "deviceCount": 0, + "color": "22" + }, + { + "name": "als 3", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "tribal 4", + "type": "Audio", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 5", + "type": "Audio", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Track 6", + "type": "MIDI", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Track 7", + "type": "MIDI", + "deviceCount": 4, + "color": "22" + }, + { + "name": "Track 8", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 9", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 10", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 11", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 12", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 13", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 14", + "type": "MIDI", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 15", + "type": "MIDI", + "deviceCount": 5, + "color": "22" + }, + { + "name": "Track 16", + "type": "MIDI", + "deviceCount": 2, + "color": "22" + }, + { + "name": "Track 17", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + }, + { + "name": "Track 18", + "type": "Grupo", + "deviceCount": 3, + "color": "22" + }, + { + "name": "Track 19", + "type": "Grupo", + "deviceCount": 1, + "color": "22" + } + ], + "stats": { + "audio": 5, + "midi": 11, + "group": 3, + "totalTracks": 19, + "devices": 42, + "scenes": 0, + "samples": 8 + }, + "samples": { + "total": 8, + "relative": [ + "Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "Samples/Imported/Clap Fx.wav", + "Samples/Imported/Clap.wav", + "Swing and Groove/Swing/Swing 16-99.agr" + ], + "absolute": [ + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/RUFUS DU SOL - In the Moment (Adriatique Remix) Acapella.mp3", + "C:/VST2/Proximity-x64.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Processed/Bounce/Bounce KICK #1 [2025-08-30 144250]-3.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 143952].wav", + "C:/Users/novik/OneDrive/Documenten/Ableton/User Library/Presets/Audio Effects/Audio Effect Rack/Filter HI and Low.adg", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Recorded/12-Audio 0001 [2025-08-30 144108].wav", + "C:/VST2/Fabfilter/FabFilter Pro-Q 4.dll", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap Fx.wav", + "C:/Users/novik/Desktop/YOUTUBE RUFUS DUU SOL & ARTBAT/GHOSTPRODUCTION.PRO (ABLETON LIVE) (Style RUFUS DUU SOL) Project/Samples/Imported/Clap.wav", + "C:/VST2/Fabfilter/FabFilter Pro-C 2.dll" + ] + }, + "storedAt": "2025-12-01T03:02:37.033Z", + "filePath": "library/38427f49e8ffcfba20969319b9dda2f7.als" } ] \ No newline at end of file diff --git a/data/flows.json b/data/flows.json index 4db4aaa..663fab3 100644 --- a/data/flows.json +++ b/data/flows.json @@ -96,7 +96,7 @@ "type": "function", "z": "f68df1c4d2e4e1a9", "name": "JSON base64 \u2192 Buffer", - "func": "const body = msg.payload;\nif (!body || typeof body.data !== 'string') {\n msg.error = 'No se recibi\u00f3 el archivo en el formato esperado.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}\nconst commaIndex = body.data.indexOf(',');\nconst base64 = commaIndex > -1 ? body.data.slice(commaIndex + 1) : body.data;\ntry {\n const buffer = Buffer.from(base64, 'base64');\n msg.payload = buffer;\n msg.filename = body.filename || 'upload.als';\n msg.size = body.size || buffer.length;\n msg.statusText = 'Archivo recibido (' + Math.round(buffer.length / 1024) + ' KB)';\n return [msg, null];\n} catch (err) {\n node.error('No se pudo decodificar el archivo ALS', err);\n msg.error = 'No se pudo decodificar el archivo ALS.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}", + "func": "const body = msg.payload;\nif (!body || typeof body.data !== 'string') {\n msg.error = 'No se recibi\u00f3 el archivo en el formato esperado.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}\nconst commaIndex = body.data.indexOf(',');\nconst base64 = commaIndex > -1 ? body.data.slice(commaIndex + 1) : body.data;\ntry {\n const buffer = Buffer.from(base64, 'base64');\n msg.payload = buffer;\n msg.originalBuffer = buffer;\n msg.filename = body.filename || 'upload.als';\n msg.size = body.size || buffer.length;\n msg.statusText = 'Archivo recibido (' + Math.round(buffer.length / 1024) + ' KB)';\n return [msg, null];\n} catch (err) {\n node.error('No se pudo decodificar el archivo ALS', err);\n msg.error = 'No se pudo decodificar el archivo ALS.';\n msg.statusCode = 400;\n msg.analysis = null;\n return [null, msg];\n}", "outputs": 2, "noerr": 0, "initialize": "", @@ -150,7 +150,7 @@ "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Registrar ALS", - "func": "const DB_PATH = '/data/als-library.json';\nfunction readLibrary() {\n try {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n return raw ? JSON.parse(raw) : [];\n } catch (err) {\n if (err.code === 'ENOENT') { return []; }\n node.error('No se pudo leer la biblioteca ALS', err);\n throw err;\n }\n}\nfunction writeLibrary(data) {\n fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));\n}\nif (!msg.analysis || !msg.analysis.hash) {\n msg.error = 'No se pudo guardar el an\u00e1lisis (hash faltante).';\n msg.statusCode = msg.statusCode || 400;\n msg.analysis = null;\n return msg;\n}\nconst library = readLibrary();\nconst existing = library.find((entry) => entry.hash === msg.analysis.hash);\nif (existing) {\n msg.error = 'Este archivo ALS ya fue registrado anteriormente.';\n msg.statusCode = 409;\n msg.statusText = 'Archivo duplicado';\n msg.analysis = null;\n return msg;\n}\nconst record = {\n ...msg.analysis,\n storedAt: new Date().toISOString()\n};\nlibrary.push(record);\nwriteLibrary(library);\nmsg.analysis = record;\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis almacenado';\nreturn msg;", + "func": "const DB_PATH = '/data/als-library.json';\nconst LIB_DIR = '/data/library';\nfunction readLibrary() {\n try {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n return raw ? JSON.parse(raw) : [];\n } catch (err) {\n if (err.code === 'ENOENT') { return []; }\n node.error('No se pudo leer la biblioteca ALS', err);\n throw err;\n }\n}\nfunction writeLibrary(data) {\n fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));\n}\nif (!msg.analysis || !msg.analysis.hash) {\n msg.error = 'No se pudo guardar el an\u00e1lisis (hash faltante).';\n msg.statusCode = msg.statusCode || 400;\n msg.analysis = null;\n return msg;\n}\nconst library = readLibrary();\nconst existing = library.find((entry) => entry.hash === msg.analysis.hash);\nif (existing) {\n msg.error = 'Este archivo ALS ya fue registrado anteriormente.';\n msg.statusCode = 409;\n msg.statusText = 'Archivo duplicado';\n msg.analysis = null;\n return msg;\n}\nconst fileName = msg.analysis.hash + '.als';\nconst filePath = path.join(LIB_DIR, fileName);\nfs.mkdirSync(LIB_DIR, { recursive: true });\nif (msg.originalBuffer && Buffer.isBuffer(msg.originalBuffer)) {\n fs.writeFileSync(filePath, msg.originalBuffer);\n}\nconst record = {\n ...msg.analysis,\n storedAt: new Date().toISOString(),\n filePath: path.posix.join('library', fileName)\n};\nlibrary.push(record);\nwriteLibrary(library);\nmsg.analysis = record;\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis almacenado';\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", @@ -159,6 +159,10 @@ { "var": "fs", "module": "fs" + }, + { + "var": "path", + "module": "path" } ], "x": 980, @@ -308,5 +312,82 @@ "x": 700, "y": 500, "wires": [] + }, + { + "id": "c87f0e0d1bdbf964", + "type": "ui_group", + "name": "Chatbot", + "tab": "c1ef7251baee3fe8", + "order": 3, + "disp": true, + "width": "12", + "collapse": false, + "className": "" + }, + { + "id": "85899363d75b2e10", + "type": "ui_template", + "z": "f68df1c4d2e4e1a9", + "group": "c87f0e0d1bdbf964", + "name": "Chatbot UI", + "order": 0, + "width": "12", + "height": "10", + "format": "\n\n \n Chatbot ALS\n \n \n \n \n {{item.role === 'user' ? 'T\u00fa' : (item.role === 'bot' ? 'ALS Bot' : 'Error')}}:\n {{item.text}}\n \n \n \n \n Escribe algo (ej. generame un als de reggaeton 2001)\n \n \n {{busy ? 'Generando...' : 'Enviar'}}\n \n \n\n\n\n", + "storeOutMessages": false, + "fwdInMessages": false, + "resendOnRefresh": true, + "templateScope": "local", + "className": "", + "x": 240, + "y": 520, + "wires": [] + }, + { + "id": "b6a5b6ec8f7a4c33", + "type": "http in", + "z": "f68df1c4d2e4e1a9", + "name": "POST /als/chat", + "url": "/als/chat", + "method": "post", + "upload": false, + "swaggerDoc": "", + "x": 220, + "y": 660, + "wires": [ + [ + "ca7c4bb1e81b009a" + ] + ] + }, + { + "id": "ca7c4bb1e81b009a", + "type": "function", + "z": "f68df1c4d2e4e1a9", + "name": "Chatbot ALS", + "func": "\nconst raw = typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload || {});\nlet body = {};\ntry {\n body = typeof msg.payload === 'object' ? msg.payload : JSON.parse(raw);\n} catch (err) {\n body = {};\n}\nconst prompt = (body.prompt || body.message || msg.prompt || '').trim();\nif (!prompt) {\n msg.statusCode = 400;\n msg.payload = { error: 'Falta el prompt del chatbot.' };\n return msg;\n}\nconst generator = global.get('alsGenerator');\nif (!generator || typeof generator.generateFromPrompt !== 'function') {\n msg.statusCode = 500;\n msg.payload = { error: 'Generador no disponible.' };\n return msg;\n}\nreturn (async () => {\n try {\n const result = await generator.generateFromPrompt(prompt);\n msg.statusCode = 200;\n msg.payload = {\n prompt: prompt,\n projectName: result.plan.projectName,\n templateHash: result.plan.templateHash,\n outputPath: result.outputPath,\n registered: result.registered\n };\n return msg;\n } catch (err) {\n node.error(err.message, msg);\n msg.statusCode = 500;\n msg.payload = { error: err.message };\n return msg;\n }\n})();\n", + "outputs": 1, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 520, + "y": 660, + "wires": [ + [ + "e0b21f83f64a49c7" + ] + ] + }, + { + "id": "e0b21f83f64a49c7", + "type": "http response", + "z": "f68df1c4d2e4e1a9", + "name": "", + "statusCode": "", + "headers": {}, + "x": 720, + "y": 660, + "wires": [] } ] \ No newline at end of file diff --git a/data/generated/.gitkeep b/data/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/lib/alsGenerator.js b/data/lib/alsGenerator.js new file mode 100644 index 0000000..e3b0b34 --- /dev/null +++ b/data/lib/alsGenerator.js @@ -0,0 +1,345 @@ +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 listSources() { + 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; +} + +async function planWithAI(prompt, library, sources) { + 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; + } + const endpoint = `${base.replace(/\/$/, '')}/v1/messages`; + 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 payload = { + model: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022', + max_tokens: 800, + temperature: 0.2, + system: + 'Eres un generador de sesiones Ableton Live (.als). Devuelves un JSON con projectName, templateHash, tempo, trackNames (array) y notes. Usa solo los hashes ofrecidos.', + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: + `Prompt del usuario: ${prompt}\n` + + `Plantillas disponibles: ${JSON.stringify(contextEntries, null, 2)}\n` + + `Sources disponibles (${sources.length}): ${sources.join(', ') || 'ninguna'}\n` + + 'Respuesta esperada (JSON): {"projectName":"","templateHash":"","tempo":120,"trackNames":["..."],"notes":"..."}' + } + ] + } + ] + }; + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': 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(); + const text = + data?.content?.[0]?.text || + data?.content?.[0]?.text || + data?.content || + ''; + return extractJson(text); + } catch (err) { + console.warn('[alsGenerator] Error llamando a la IA:', err.message); + return null; + } +} + +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 = listSources(); + 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 + }; +} + +module.exports = { + generateFromPrompt +}; diff --git a/data/library/.gitkeep b/data/library/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/package-lock.json b/data/package-lock.json index 6d982fc..a5e2da4 100644 --- a/data/package-lock.json +++ b/data/package-lock.json @@ -10,9 +10,9 @@ "license": "ISC", "dependencies": { "fast-xml-parser": "^5.3.2", + "node-fetch": "^2.7.0", "node-red-dashboard": "^3.6.6" - }, - "devDependencies": {} + } }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", @@ -327,6 +327,25 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-red-dashboard": { "version": "3.6.6", "resolved": "https://registry.npmjs.org/node-red-dashboard/-/node-red-dashboard-3.6.6.tgz", @@ -588,6 +607,11 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -601,6 +625,20 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", @@ -858,6 +896,14 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==" }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "node-red-dashboard": { "version": "3.6.6", "resolved": "https://registry.npmjs.org/node-red-dashboard/-/node-red-dashboard-3.6.6.tgz", @@ -1042,6 +1088,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -1052,6 +1103,20 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/data/package.json b/data/package.json index 2af4a3d..b815a57 100644 --- a/data/package.json +++ b/data/package.json @@ -15,6 +15,7 @@ "license": "ISC", "dependencies": { "fast-xml-parser": "^5.3.2", + "node-fetch": "^2.7.0", "node-red-dashboard": "^3.6.6" } } diff --git a/data/settings.js b/data/settings.js index 690692b..1a08550 100644 --- a/data/settings.js +++ b/data/settings.js @@ -480,7 +480,7 @@ module.exports = { * global.get("os") */ functionGlobalContext: { - // os:require('os'), + alsGenerator: require('./lib/alsGenerator.js'), }, /** The maximum number of messages nodes will buffer internally as part of their diff --git a/data/sources/.gitkeep b/data/sources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/chatbot.js b/scripts/chatbot.js new file mode 100755 index 0000000..c248a02 --- /dev/null +++ b/scripts/chatbot.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +const readline = require('readline'); +const { generateFromPrompt } = require('../data/lib/alsGenerator'); +const path = require('path'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'als-bot> ' +}); + +console.log('Chatbot ALS listo. Escribe "generame un als de ..." o "salir".'); +rl.prompt(); + +rl.on('line', async (line) => { + const input = line.trim(); + if (!input) { + rl.prompt(); + return; + } + if (['salir', 'exit', 'quit'].includes(input.toLowerCase())) { + rl.close(); + return; + } + try { + const result = await generateFromPrompt(input); + console.log('Nuevo ALS listo:', { + projectName: result.plan.projectName, + output: path.relative(process.cwd(), result.outputPath), + templateHash: result.plan.templateHash + }); + } catch (err) { + console.error('No se pudo generar el ALS:', err.message); + } + rl.prompt(); +}); + +rl.on('close', () => { + console.log('Hasta luego 👋'); + process.exit(0); +}); diff --git a/scripts/generate-als.js b/scripts/generate-als.js new file mode 100755 index 0000000..1e439fa --- /dev/null +++ b/scripts/generate-als.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +const path = require('path'); +const { generateFromPrompt } = require('../data/lib/alsGenerator'); + +async function main() { + const prompt = process.argv.slice(2).join(' ').trim(); + if (!prompt) { + console.error('Uso: node scripts/generate-als.js \"generame un als de reggaeton\"'); + process.exit(1); + } + try { + const result = await generateFromPrompt(prompt); + console.log('✅ ALS generado:', { + projectName: result.plan.projectName, + outputPath: path.relative(process.cwd(), result.outputPath), + templateHash: result.plan.templateHash, + registered: result.registered + }); + } catch (err) { + console.error('❌ Error generando ALS:', err.message); + process.exit(1); + } +} + +main();