[ { "id": "f68df1c4d2e4e1a9", "type": "tab", "label": "ALS Inspector", "disabled": false, "info": "Dashboard para analizar archivos Ableton Live (.als)" }, { "id": "c1ef7251baee3fe8", "type": "ui_tab", "name": "Ableton ALS", "icon": "dashboard", "disabled": false, "hidden": false }, { "id": "2997d0427c0f3a34", "type": "ui_group", "name": "Carga", "tab": "c1ef7251baee3fe8", "order": 1, "disp": true, "width": "12", "collapse": false, "className": "" }, { "id": "f93a3673d299cf3c", "type": "ui_group", "name": "Resumen", "tab": "c1ef7251baee3fe8", "order": 2, "disp": true, "width": "12", "collapse": false, "className": "" }, { "id": "9eae4d30c9181132", "type": "ui_template", "z": "f68df1c4d2e4e1a9", "group": "2997d0427c0f3a34", "name": "Subir ALS", "order": 0, "width": "12", "height": "7", "format": "\n \n Inspector de proyectos ALS\n \n \n

Selecciona un archivo .als. Se enviar\u00e1 al endpoint /als/upload y, si es nuevo, se almacenar\u00e1 en la biblioteca.

\n \n

{{statusText}}

\n
\n
\n", "storeOutMessages": false, "fwdInMessages": false, "resendOnRefresh": true, "templateScope": "local", "className": "", "x": 230, "y": 100, "wires": [] }, { "id": "f9c6054bed66e56e", "type": "ui_template", "z": "f68df1c4d2e4e1a9", "group": "f93a3673d299cf3c", "name": "Resumen ALS", "order": 0, "width": "12", "height": "14", "format": "\n \n Resumen del proyecto\n \n \n
\n
\n
Proyecto
\n
{{msg.payload.projectName}}
\n
\n
\n
Tempo
\n
{{msg.payload.liveSet.tempo || 'N/D'}} BPM
\n
\n
\n
Duraci\u00f3n aprox.
\n
{{msg.payload.liveSet.durationHuman || 'N/D'}}
\n
\n
\n
Escenas
\n
{{msg.payload.stats.scenes}}
\n
\n
\n
Samples
\n
{{msg.payload.samples.total}}
\n
\n
\n
Dispositivos
\n
{{msg.payload.stats.devices}}
\n
\n
\n
Hash
\n
{{msg.payload.hash | limitTo:12}}\u2026
\n
\n
\n
Guardado
\n
{{msg.payload.storedAt | date:'medium'}}
\n
\n
\n
\n

Versi\u00f3n: {{msg.payload.meta.creator || 'Desconocido'}} {{msg.payload.meta.version}}

\n

Archivo: {{msg.payload.meta.fileName || '\u2014'}} \u00b7 {{msg.payload.meta.sizeHuman}}

\n
\n

Tracks ({{msg.payload.stats.totalTracks}})

\n
\n
\n
{{track.name || '(sin nombre)'}}
\n
{{track.type}} \u00b7 {{track.deviceCount}} dispositivos
\n
\n
\n

Escenas

\n \n \n

{{scene || '(sin nombre)'}}

\n
\n
\n

Muestras referenciadas

\n \n \n

{{path}}

\n
\n
\n
\n \n

Sube un archivo .als para ver el an\u00e1lisis y guardarlo.

\n
\n \n

{{msg.error}}

\n
\n
\n", "storeOutMessages": false, "fwdInMessages": false, "resendOnRefresh": true, "templateScope": "local", "className": "", "x": 1240, "y": 320, "wires": [] }, { "id": "7d93f766ed4124fd", "type": "http in", "z": "f68df1c4d2e4e1a9", "name": "POST /als/upload", "url": "/als/upload", "method": "post", "upload": false, "swaggerDoc": "", "x": 180, "y": 320, "wires": [ [ "5df1cfb22fbc2a0c" ] ] }, { "id": "5df1cfb22fbc2a0c", "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.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": "", "finalize": "", "libs": [], "x": 460, "y": 320, "wires": [ [ "84ef635c6b70bb7c" ], [ "4bcaddf8a5c2b6c9" ] ] }, { "id": "84ef635c6b70bb7c", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Inspector ALS", "func": "function toArray(value) {\n if (!value) { return []; }\n return Array.isArray(value) ? value : [value];\n}\nfunction readName(entity) {\n if (!entity || !entity.Name) { return ''; }\n const name = entity.Name;\n if (name.EffectiveName && name.EffectiveName.Value) { return name.EffectiveName.Value; }\n if (name.UserName && name.UserName.Value) { return name.UserName.Value; }\n if (name.Value) { return name.Value; }\n return '';\n}\nfunction countDevices(node) {\n if (!node || typeof node !== 'object') { return 0; }\n let total = 0;\n Object.values(node).forEach((value) => {\n if (!value) { return; }\n if (Array.isArray(value)) {\n total += value.length;\n } else if (typeof value === 'object') {\n total += 1;\n }\n });\n return total;\n}\nfunction humanFileSize(bytes) {\n if (!bytes && bytes !== 0) { return 'N/D'; }\n if (bytes < 1024) { return bytes + ' B'; }\n const units = ['KB', 'MB', 'GB', 'TB'];\n let value = bytes / 1024;\n let unit = units[0];\n for (let i = 0; i < units.length; i += 1) {\n unit = units[i];\n if (value < 1024 || i === units.length - 1) { break; }\n value /= 1024;\n }\n return value.toFixed(1) + ' ' + unit;\n}\nfunction secondsToHuman(seconds) {\n if (!seconds && seconds !== 0) { return null; }\n const total = Math.round(seconds);\n const mins = Math.floor(total / 60);\n const secs = String(total % 60).padStart(2, '0');\n return mins + ':' + secs;\n}\nfunction handlePathField(field, bucket) {\n if (!field) { return; }\n if (typeof field === 'string') {\n bucket.add(field);\n } else if (Array.isArray(field)) {\n field.forEach((item) => handlePathField(item, bucket));\n } else if (typeof field === 'object' && field.Value) {\n bucket.add(field.Value);\n }\n}\nfunction collectRefs(node, bag) {\n if (!node || typeof node !== 'object') { return; }\n handlePathField(node.RelativePath, bag.relative);\n handlePathField(node.Path, bag.absolute);\n Object.values(node).forEach((value) => {\n if (Array.isArray(value)) {\n value.forEach((item) => collectRefs(item, bag));\n } else if (value && typeof value === 'object') {\n collectRefs(value, bag);\n }\n });\n}\nif (!Buffer.isBuffer(msg.payload)) {\n msg.error = 'No se recibi\u00f3 un Buffer con el ALS.';\n msg.analysis = null;\n msg.statusCode = 400;\n return msg;\n}\nconst originalSize = msg.size || msg.payload.length;\nlet xmlBuffer;\ntry {\n xmlBuffer = zlib.gunzipSync(msg.payload);\n} catch (err) {\n msg.error = 'El archivo no parece ser un ALS v\u00e1lido (fall\u00f3 la descompresi\u00f3n).';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nlet doc;\ntry {\n const ParserClass = fastXmlParser.XMLParser || fastXmlParser;\n const parser = new ParserClass({\n ignoreAttributes: false,\n attributeNamePrefix: '',\n parseAttributeValue: false,\n allowBooleanAttributes: true\n });\n doc = parser.parse(xmlBuffer.toString('utf8'));\n} catch (err) {\n msg.error = 'No se pudo parsear el XML del proyecto.';\n msg.analysis = null;\n msg.statusCode = 400;\n node.error(err, msg);\n return msg;\n}\nconst ableton = doc?.Ableton || {};\nconst liveSet = ableton.LiveSet || {};\nconst projectName = readName(liveSet) || msg.filename || 'Proyecto sin nombre';\nconst tempoValue = liveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? liveSet?.DeviceChain?.Mixer?.Tempo?.Manual?.Value\n ?? null;\nconst tempo = tempoValue ? Number(tempoValue) : null;\nconst loopLengthBeats = Number(liveSet?.Transport?.LoopLength?.Value) || null;\nconst durationSeconds = tempo && loopLengthBeats ? (loopLengthBeats * 60) / tempo : null;\nconst trackBuckets = [\n { key: 'AudioTrack', label: 'Audio', stat: 'audio' },\n { key: 'MidiTrack', label: 'MIDI', stat: 'midi' },\n { key: 'GroupTrack', label: 'Grupo', stat: 'group' }\n];\nconst stats = { audio: 0, midi: 0, group: 0, devices: 0 };\nconst tracks = [];\nconst trackNode = liveSet?.Tracks || {};\ntrackBuckets.forEach((bucket) => {\n toArray(trackNode[bucket.key]).forEach((track) => {\n stats[bucket.stat] += 1;\n const deviceCount = countDevices(track?.DeviceChain?.DeviceChain?.Devices);\n stats.devices += deviceCount;\n tracks.push({\n name: readName(track) || bucket.label + ' ' + stats[bucket.stat],\n type: bucket.label,\n deviceCount,\n color: track?.Color?.Value || null\n });\n });\n});\nconst scenes = toArray(liveSet?.Scenes?.Scene)\n .map((scene) => readName(scene))\n .filter((name) => name !== '');\nconst sampleRefs = { relative: new Set(), absolute: new Set() };\ncollectRefs(liveSet, sampleRefs);\nconst sampleSummary = {\n total: sampleRefs.relative.size || sampleRefs.absolute.size,\n relative: Array.from(sampleRefs.relative).slice(0, 15),\n absolute: Array.from(sampleRefs.absolute).slice(0, 10)\n};\nconst hash = crypto.createHash('md5').update(msg.payload).digest('hex');\nmsg.analysis = {\n hash,\n projectName,\n meta: {\n creator: ableton.Creator || null,\n version: [ableton.MajorVersion, ableton.MinorVersion].filter(Boolean).join('.'),\n revision: ableton.Revision || null,\n fileName: msg.filename || null,\n sizeBytes: originalSize,\n sizeHuman: humanFileSize(originalSize)\n },\n liveSet: {\n tempo,\n loopLengthBeats,\n durationSeconds,\n durationHuman: secondsToHuman(durationSeconds),\n loopStart: Number(liveSet?.Transport?.LoopStart?.Value) || 0,\n scenes\n },\n tracks,\n stats: {\n audio: stats.audio,\n midi: stats.midi,\n group: stats.group,\n totalTracks: tracks.length,\n devices: stats.devices,\n scenes: scenes.length,\n samples: sampleSummary.total\n },\n samples: sampleSummary\n};\nmsg.error = null;\nmsg.statusCode = 200;\nmsg.statusText = 'An\u00e1lisis generado';\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [ { "var": "fastXmlParser", "module": "fast-xml-parser" }, { "var": "zlib", "module": "zlib" }, { "var": "crypto", "module": "crypto" } ], "x": 750, "y": 300, "wires": [ [ "b0b4475c1cd1a2f1" ] ] }, { "id": "b0b4475c1cd1a2f1", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Registrar ALS", "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": "", "finalize": "", "libs": [ { "var": "fs", "module": "fs" }, { "var": "path", "module": "path" } ], "x": 980, "y": 300, "wires": [ [ "d2bc9fd9a04fde13", "f64b6c9ce8b18591" ] ] }, { "id": "4bcaddf8a5c2b6c9", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Solicitud inv\u00e1lida", "func": "if (!msg.error) {\n msg.error = 'No se pudo procesar el archivo.';\n}\nif (!msg.statusCode) {\n msg.statusCode = 400;\n}\nmsg.analysis = null;\nmsg.statusText = 'Error al analizar el archivo';\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 760, "y": 400, "wires": [ [ "d2bc9fd9a04fde13", "f64b6c9ce8b18591" ] ] }, { "id": "d2bc9fd9a04fde13", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Preparar respuesta HTTP", "func": "if (!msg.statusCode) {\n msg.statusCode = msg.error ? 400 : 200;\n}\nmsg.payload = msg.error ? { error: msg.error } : msg.analysis;\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1240, "y": 240, "wires": [ [ "3c1fe83bb7f4a173" ] ] }, { "id": "3c1fe83bb7f4a173", "type": "http response", "z": "f68df1c4d2e4e1a9", "name": "", "statusCode": "", "headers": {}, "x": 1440, "y": 240, "wires": [] }, { "id": "f64b6c9ce8b18591", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Enviar al dashboard", "func": "return {\n payload: msg.analysis,\n error: msg.error || null,\n statusText: msg.statusText || ''\n};", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1240, "y": 380, "wires": [ [ "f9c6054bed66e56e", "dc3db36cf0f12d58" ] ] }, { "id": "dc3db36cf0f12d58", "type": "debug", "z": "f68df1c4d2e4e1a9", "name": "Resumen debug", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "statusVal": "", "statusType": "auto", "x": 1450, "y": 440, "wires": [] }, { "id": "5d10419c2babf6bc", "type": "http in", "z": "f68df1c4d2e4e1a9", "name": "GET /als/library", "url": "/als/library", "method": "get", "upload": false, "swaggerDoc": "", "x": 200, "y": 500, "wires": [ [ "8c33f8cbb1c2669c" ] ] }, { "id": "8c33f8cbb1c2669c", "type": "function", "z": "f68df1c4d2e4e1a9", "name": "Listar biblioteca ALS", "func": "const DB_PATH = '/data/als-library.json';\ntry {\n const raw = fs.readFileSync(DB_PATH, 'utf8');\n const data = raw ? JSON.parse(raw) : [];\n msg.payload = data;\n msg.statusCode = 200;\n} catch (err) {\n if (err.code === 'ENOENT') {\n msg.payload = [];\n msg.statusCode = 200;\n } else {\n node.error('No se pudo leer la biblioteca ALS', err);\n msg.statusCode = 500;\n msg.payload = { error: 'No se pudo leer la biblioteca ALS' };\n }\n}\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", "finalize": "", "libs": [ { "var": "fs", "module": "fs" } ], "x": 480, "y": 500, "wires": [ [ "0f16a9fca5dce956" ] ] }, { "id": "0f16a9fca5dce956", "type": "http response", "z": "f68df1c4d2e4e1a9", "name": "", "statusCode": "", "headers": {}, "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 \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}\nfunction shouldGenerate(text) {\n return /(genera(me)?|crea(me)?|haz(me)?|arma(me)?|produce(me)?).*(als|ableton|beat|proyecto)/i.test(text);\n}\nfunction buildSummary(text, library, sources) {\n if (!library || !library.length) {\n return 'A\u00fan no tengo proyectos cargados. Sube un .als primero y luego dime \"generame un als ...\" para empezar.';\n }\n const recent = library.slice(-1)[0];\n const parts = [\n `Tengo ${library.length} proyectos almacenados. El \u00faltimo fue \"${recent.projectName}\" a ${recent.liveSet?.tempo || 'N/D'} BPM.`,\n sources && sources.length ? `Hay ${sources.length} recursos en sources (ej: ${sources.slice(0,3).join(', ')})` : 'A\u00fan no hay archivos en data/sources/.',\n 'Cuando est\u00e9s listo dime algo como \"generame un als afrohouse 2025 con 124 bpm\" y preparo la sesi\u00f3n.'\n ];\n return parts.join(' ');\n}\nif (!shouldGenerate(prompt)) {\n const info = generator.listLibrary ? generator.listLibrary() : [];\n const sources = generator.listSources ? generator.listSources() : [];\n msg.statusCode = 200;\n msg.payload = { reply: buildSummary(prompt, info, sources) };\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": [] } ]