#!/usr/bin/env node /** * ADR-076: CSI Spectrogram Embedding Pipeline * * Converts raw CSI frames into 128-dim CNN embeddings by treating the * subcarrier x time matrix as a grayscale spectrogram image. * * Modes: * --live Listen on UDP for real-time CSI frames * --file FILE Read from a .csi.jsonl recording * --ascii Print ASCII spectrogram visualization * --ingest Send 128-dim embeddings to Cognitum Seed * --knn K Find K most similar past spectrograms * * Usage: * node scripts/csi-spectrogram.js --file data/recordings/pretrain-1775182186.csi.jsonl --ascii * node scripts/csi-spectrogram.js --live --port 5006 --ingest --seed-url https://169.254.42.1:8443 * node scripts/csi-spectrogram.js --file data/recordings/pretrain-1775182186.csi.jsonl --knn 5 * * ADR: docs/adr/ADR-076-csi-spectrogram-embeddings.md */ 'use strict'; const dgram = require('dgram'); const fs = require('fs'); const path = require('path'); const readline = require('readline'); const { parseArgs } = require('util'); // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const { values: args } = parseArgs({ options: { file: { type: 'string', short: 'f' }, live: { type: 'boolean', default: false }, port: { type: 'string', short: 'p', default: '5006' }, ascii: { type: 'boolean', default: false }, ingest: { type: 'boolean', default: false }, knn: { type: 'string', short: 'k' }, 'seed-url': { type: 'string', default: 'https://169.254.42.1:8443' }, 'seed-token': { type: 'string', default: '' }, window: { type: 'string', short: 'w', default: '20' }, stride: { type: 'string', short: 's', default: '10' }, dim: { type: 'string', short: 'd', default: '128' }, json: { type: 'boolean', default: false }, limit: { type: 'string', short: 'l' }, }, strict: true, }); const WINDOW_SIZE = parseInt(args.window, 10); // frames per spectrogram const STRIDE = parseInt(args.stride, 10); // frames between windows const EMBED_DIM = parseInt(args.dim, 10); // CNN output dimension const KNN_K = args.knn ? parseInt(args.knn, 10) : 0; const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity; const PORT = parseInt(args.port, 10); const JSON_OUTPUT = args.json; // ADR-018 packet constants const CSI_MAGIC = 0xC5110001; const HEADER_SIZE = 20; // CNN input size (ruvector/cnn expects 224x224 RGB) const CNN_INPUT_SIZE = 224; // ASCII visualization characters (8 intensity levels) const BARS = [' ', '\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']; // --------------------------------------------------------------------------- // IQ Hex Parsing // --------------------------------------------------------------------------- /** * Parse iq_hex string into subcarrier amplitudes. * Format: 4 hex chars per subcarrier (I byte + Q byte). * @param {string} iqHex - Hex-encoded I/Q data * @param {number} nSubcarriers - Expected number of subcarriers * @returns {Float32Array} Amplitude per subcarrier */ function parseIqHex(iqHex, nSubcarriers) { const amps = new Float32Array(nSubcarriers); for (let sc = 0; sc < nSubcarriers; sc++) { const offset = sc * 4; if (offset + 4 > iqHex.length) break; const iVal = parseInt(iqHex.substring(offset, offset + 2), 16); const qVal = parseInt(iqHex.substring(offset + 2, offset + 4), 16); amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal); } return amps; } /** * Parse an ADR-018 binary UDP packet into subcarrier amplitudes. * @param {Buffer} buf - Raw UDP packet * @returns {{ nodeId: number, rssi: number, nSubcarriers: number, amplitudes: Float32Array } | null} */ function parseBinaryFrame(buf) { if (buf.length < HEADER_SIZE) return null; const magic = buf.readUInt32LE(0); if (magic !== CSI_MAGIC) return null; const nodeId = buf.readUInt8(4); const rssi = buf.readInt8(5); const nSubcarriers = buf.readUInt16LE(6); const payloadSize = buf.readUInt16LE(8); if (buf.length < HEADER_SIZE + payloadSize) return null; const amps = new Float32Array(nSubcarriers); for (let sc = 0; sc < nSubcarriers; sc++) { const off = HEADER_SIZE + sc * 2; if (off + 2 > buf.length) break; const iVal = buf[off]; const qVal = buf[off + 1]; amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal); } return { nodeId, rssi, nSubcarriers, amplitudes: amps }; } // --------------------------------------------------------------------------- // Spectrogram Window // --------------------------------------------------------------------------- class SpectrogramWindow { /** * @param {number} nSubcarriers - Number of subcarriers per frame * @param {number} windowSize - Number of time frames per window */ constructor(nSubcarriers, windowSize) { this.nSubcarriers = nSubcarriers; this.windowSize = windowSize; /** @type {Float32Array[]} Ring buffer of amplitude vectors */ this.frames = []; this.totalPushed = 0; } /** Push a new amplitude vector. */ push(amplitudes) { if (amplitudes.length !== this.nSubcarriers) { // Pad or truncate to expected size const padded = new Float32Array(this.nSubcarriers); padded.set(amplitudes.subarray(0, Math.min(amplitudes.length, this.nSubcarriers))); this.frames.push(padded); } else { this.frames.push(new Float32Array(amplitudes)); } if (this.frames.length > this.windowSize) { this.frames.shift(); } this.totalPushed++; } /** @returns {boolean} True when window is full */ isFull() { return this.frames.length >= this.windowSize; } /** * Get the subcarrier x time matrix as a flat grayscale image (0-255). * Layout: row-major, rows = subcarriers, cols = time frames. * @returns {{ pixels: Uint8Array, width: number, height: number }} */ toGrayscale() { const h = this.nSubcarriers; const w = this.windowSize; const pixels = new Uint8Array(h * w); // Find min/max across entire window for normalization let min = Infinity; let max = -Infinity; for (let t = 0; t < w; t++) { const frame = this.frames[t]; for (let sc = 0; sc < h; sc++) { const v = frame[sc]; if (v < min) min = v; if (v > max) max = v; } } const range = max - min || 1; for (let sc = 0; sc < h; sc++) { for (let t = 0; t < w; t++) { const v = this.frames[t][sc]; pixels[sc * w + t] = Math.round(255 * (v - min) / range); } } return { pixels, width: w, height: h }; } /** * Upsample grayscale to CNN input size using nearest-neighbor interpolation. * Replicates to 3-channel RGB as required by @ruvector/cnn. * @returns {Uint8Array} RGB pixel data (CNN_INPUT_SIZE * CNN_INPUT_SIZE * 3) */ toCnnInput() { const { pixels, width, height } = this.toGrayscale(); const out = new Uint8Array(CNN_INPUT_SIZE * CNN_INPUT_SIZE * 3); for (let y = 0; y < CNN_INPUT_SIZE; y++) { const srcY = Math.min(Math.floor(y * height / CNN_INPUT_SIZE), height - 1); for (let x = 0; x < CNN_INPUT_SIZE; x++) { const srcX = Math.min(Math.floor(x * width / CNN_INPUT_SIZE), width - 1); const gray = pixels[srcY * width + srcX]; const dstIdx = (y * CNN_INPUT_SIZE + x) * 3; out[dstIdx] = gray; out[dstIdx + 1] = gray; out[dstIdx + 2] = gray; } } return out; } } // --------------------------------------------------------------------------- // ASCII Visualization // --------------------------------------------------------------------------- /** * Print an ASCII spectrogram of the current window. * Rows = subcarrier index (downsampled), columns = time. */ function printAsciiSpectrogram(window, meta = {}) { const { pixels, width, height } = window.toGrayscale(); // Downsample rows to fit terminal (max 32 rows) const maxRows = Math.min(height, 32); const rowStep = Math.ceil(height / maxRows); const lines = []; lines.push(`--- Spectrogram [${height}sc x ${width}t] node=${meta.nodeId || '?'} rssi=${meta.rssi || '?'} ---`); for (let r = 0; r < maxRows; r++) { const sc = r * rowStep; const label = String(sc).padStart(3); let row = `sc${label} |`; for (let t = 0; t < width; t++) { const v = pixels[sc * width + t]; const level = Math.min(Math.floor(v / 29), BARS.length - 1); row += BARS[level]; } row += '|'; lines.push(row); } lines.push(` ${''.padStart(width + 2, '-')}`); lines.push(` t=0${''.padStart(width - 6)}t=${width - 1}`); console.log(lines.join('\n')); } // --------------------------------------------------------------------------- // CNN Embedding // --------------------------------------------------------------------------- let cnnEmbedder = null; let cnnInitialized = false; /** * Initialize the CNN embedder from vendor WASM. */ async function initCnn() { if (cnnInitialized) return; // Load WASM bindings directly to work around the CnnEmbedder wrapper bug: // The wrapper's constructor calls `new wasm.WasmCnnEmbedder(wasmConfig)` which // consumes (destroys) the EmbedderConfig pointer, then tries to read // `wasmConfig.embedding_dim` from the now-null pointer. We use the WASM // classes directly and track the dimension ourselves. const wasmPath = path.resolve( __dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'ruvector-cnn' ); const wasmModule = require(path.join(wasmPath, 'ruvector_cnn_wasm.js')); const wasmBuffer = fs.readFileSync(path.join(wasmPath, 'ruvector_cnn_wasm_bg.wasm')); await wasmModule.default(wasmBuffer); const config = new wasmModule.EmbedderConfig(); config.input_size = CNN_INPUT_SIZE; config.embedding_dim = EMBED_DIM; config.normalize = true; // Save dim before construction (constructor consumes config) const savedDim = EMBED_DIM; const inner = new wasmModule.WasmCnnEmbedder(config); // Wrap in a compatible interface cnnEmbedder = { _inner: inner, embeddingDim: savedDim, extract(imageData, width, height) { return new Float32Array(inner.extract(imageData, width, height)); }, cosineSimilarity(a, b) { return inner.cosine_similarity(a, b); }, }; cnnInitialized = true; if (!JSON_OUTPUT) { console.log(`[cnn] Initialized: embeddingDim=${savedDim}, inputSize=${CNN_INPUT_SIZE}x${CNN_INPUT_SIZE}`); } } /** * Extract CNN embedding from a spectrogram window. * @param {SpectrogramWindow} window * @returns {Float32Array} 128-dim embedding */ function extractEmbedding(window) { const rgbPixels = window.toCnnInput(); return cnnEmbedder.extract(rgbPixels, CNN_INPUT_SIZE, CNN_INPUT_SIZE); } // --------------------------------------------------------------------------- // Embedding Store (in-memory kNN) // --------------------------------------------------------------------------- class EmbeddingStore { constructor() { /** @type {{ embedding: Float32Array, timestamp: number, nodeId: number, windowIdx: number }[]} */ this.entries = []; } add(embedding, meta) { this.entries.push({ embedding, ...meta }); } /** * Find k nearest neighbors by cosine similarity. * @param {Float32Array} query * @param {number} k * @returns {{ index: number, similarity: number, meta: object }[]} */ knn(query, k) { const scores = this.entries.map((entry, index) => ({ index, similarity: cosineSimilarity(query, entry.embedding), timestamp: entry.timestamp, nodeId: entry.nodeId, windowIdx: entry.windowIdx, })); scores.sort((a, b) => b.similarity - a.similarity); return scores.slice(0, k); } get size() { return this.entries.length; } } function cosineSimilarity(a, b) { let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denom = Math.sqrt(normA) * Math.sqrt(normB); return denom > 0 ? dot / denom : 0; } // --------------------------------------------------------------------------- // Cognitum Seed Ingest // --------------------------------------------------------------------------- /** * Send a 128-dim embedding to Cognitum Seed's RVF vector store. * @param {Float32Array} embedding * @param {object} meta */ async function ingestToSeed(embedding, meta) { const seedUrl = args['seed-url']; const token = args['seed-token'] || process.env.SEED_TOKEN; if (!token) { console.error('[seed] No token provided (--seed-token or $SEED_TOKEN)'); return; } const https = require('https'); const payload = JSON.stringify({ store: 'csi-spectrograms', vectors: [{ id: `spectrogram-${meta.nodeId}-${meta.windowIdx}`, values: Array.from(embedding), metadata: { node_id: meta.nodeId, timestamp: meta.timestamp, window_idx: meta.windowIdx, rssi: meta.rssi, subcarriers: meta.nSubcarriers, }, }], }); return new Promise((resolve, reject) => { const url = new URL('/v1/vectors/upsert', seedUrl); const req = https.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'Content-Length': Buffer.byteLength(payload), }, rejectUnauthorized: false, }, (res) => { let body = ''; res.on('data', (chunk) => body += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(JSON.parse(body)); } else { reject(new Error(`Seed HTTP ${res.statusCode}: ${body}`)); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } // --------------------------------------------------------------------------- // File Mode: Read JSONL Recording // --------------------------------------------------------------------------- async function processFile(filePath) { await initCnn(); const store = new EmbeddingStore(); const windows = new Map(); // nodeId -> SpectrogramWindow const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity, }); let frameCount = 0; let windowCount = 0; let lastNodeId = 0; let lastRssi = 0; for await (const line of rl) { if (frameCount >= LIMIT) break; let frame; try { frame = JSON.parse(line); } catch { continue; } const nodeId = frame.node_id || 0; const nSubcarriers = frame.subcarriers || 64; const iqHex = frame.iq_hex || ''; if (!iqHex) continue; const amplitudes = parseIqHex(iqHex, nSubcarriers); lastNodeId = nodeId; lastRssi = frame.rssi || 0; if (!windows.has(nodeId)) { windows.set(nodeId, new SpectrogramWindow(nSubcarriers, WINDOW_SIZE)); } const win = windows.get(nodeId); win.push(amplitudes); frameCount++; // Check if this window is ready and stride condition met if (win.isFull() && (win.totalPushed - WINDOW_SIZE) % STRIDE === 0) { const t0 = Date.now(); const embedding = extractEmbedding(win); const embedMs = Date.now() - t0; const meta = { timestamp: frame.timestamp, nodeId, windowIdx: windowCount, rssi: frame.rssi || 0, nSubcarriers, }; store.add(embedding, meta); if (args.ascii) { printAsciiSpectrogram(win, { nodeId, rssi: frame.rssi }); } if (JSON_OUTPUT) { console.log(JSON.stringify({ type: 'embedding', windowIdx: windowCount, nodeId, dim: embedding.length, embedMs, embedding: Array.from(embedding).map(v => +v.toFixed(6)), })); } else { const embSnippet = Array.from(embedding.subarray(0, 4)).map(v => v.toFixed(4)).join(', '); console.log(`[window ${windowCount}] node=${nodeId} embed=[${embSnippet}, ...] (${embedMs}ms)`); } // kNN search against previous windows if (KNN_K > 0 && store.size > 1) { const neighbors = store.knn(embedding, KNN_K + 1); // Skip self (first result) const results = neighbors.filter(n => n.windowIdx !== windowCount).slice(0, KNN_K); if (JSON_OUTPUT) { console.log(JSON.stringify({ type: 'knn', query: windowCount, results })); } else { console.log(` kNN(${KNN_K}): ${results.map(r => `w${r.windowIdx}(${r.similarity.toFixed(3)})`).join(' ')}`); } } // Cognitum Seed ingest if (args.ingest) { try { await ingestToSeed(embedding, meta); if (!JSON_OUTPUT) console.log(` -> ingested to Seed`); } catch (err) { console.error(` -> Seed ingest failed: ${err.message}`); } } windowCount++; } } if (!JSON_OUTPUT) { console.log(`\nProcessed ${frameCount} frames -> ${windowCount} spectrogram windows`); console.log(`Store contains ${store.size} embeddings of dimension ${EMBED_DIM}`); } return store; } // --------------------------------------------------------------------------- // Live Mode: UDP Listener // --------------------------------------------------------------------------- async function processLive() { await initCnn(); const store = new EmbeddingStore(); const windows = new Map(); let windowCount = 0; const server = dgram.createSocket('udp4'); server.on('message', async (msg, rinfo) => { // Try binary ADR-018 format first let parsed = parseBinaryFrame(msg); let nodeId, nSubcarriers, amplitudes, rssi; if (parsed) { nodeId = parsed.nodeId; nSubcarriers = parsed.nSubcarriers; amplitudes = parsed.amplitudes; rssi = parsed.rssi; } else { // Try JSONL format try { const frame = JSON.parse(msg.toString()); nodeId = frame.node_id || 0; nSubcarriers = frame.subcarriers || 64; amplitudes = parseIqHex(frame.iq_hex || '', nSubcarriers); rssi = frame.rssi || 0; } catch { return; // Unknown format } } if (!windows.has(nodeId)) { windows.set(nodeId, new SpectrogramWindow(nSubcarriers, WINDOW_SIZE)); } const win = windows.get(nodeId); win.push(amplitudes); if (win.isFull() && (win.totalPushed - WINDOW_SIZE) % STRIDE === 0) { const t0 = Date.now(); const embedding = extractEmbedding(win); const embedMs = Date.now() - t0; const meta = { timestamp: Date.now() / 1000, nodeId, windowIdx: windowCount, rssi, nSubcarriers, }; store.add(embedding, meta); if (args.ascii) { printAsciiSpectrogram(win, { nodeId, rssi }); } if (JSON_OUTPUT) { console.log(JSON.stringify({ type: 'embedding', windowIdx: windowCount, nodeId, dim: embedding.length, embedMs, embedding: Array.from(embedding).map(v => +v.toFixed(6)), })); } else { const embSnippet = Array.from(embedding.subarray(0, 4)).map(v => v.toFixed(4)).join(', '); console.log(`[window ${windowCount}] node=${nodeId} rssi=${rssi} embed=[${embSnippet}, ...] (${embedMs}ms)`); } if (KNN_K > 0 && store.size > 1) { const neighbors = store.knn(embedding, KNN_K + 1); const results = neighbors.filter(n => n.windowIdx !== windowCount).slice(0, KNN_K); if (!JSON_OUTPUT) { console.log(` kNN(${KNN_K}): ${results.map(r => `w${r.windowIdx}(${r.similarity.toFixed(3)})`).join(' ')}`); } } if (args.ingest) { try { await ingestToSeed(embedding, meta); } catch (err) { console.error(` -> Seed ingest failed: ${err.message}`); } } windowCount++; } }); server.on('listening', () => { const addr = server.address(); console.log(`[live] Listening for CSI on UDP ${addr.address}:${addr.port}`); console.log(`[live] Window: ${WINDOW_SIZE} frames, stride: ${STRIDE}, embed dim: ${EMBED_DIM}`); if (KNN_K > 0) console.log(`[live] kNN search: k=${KNN_K}`); if (args.ingest) console.log(`[live] Ingesting to Cognitum Seed at ${args['seed-url']}`); }); server.bind(PORT); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { if (!args.file && !args.live) { console.error('Usage: node scripts/csi-spectrogram.js --file [--ascii] [--knn K]'); console.error(' node scripts/csi-spectrogram.js --live [--port 5006] [--ingest]'); process.exit(1); } if (args.file) { const filePath = path.resolve(args.file); if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); } await processFile(filePath); } else { await processLive(); } } main().catch((err) => { console.error('Fatal:', err); process.exit(1); });