diff --git a/src/core/embedding/bitstream/preamble.ts b/src/core/embedding/bitstream/preamble.ts new file mode 100644 index 0000000..f6503af --- /dev/null +++ b/src/core/embedding/bitstream/preamble.ts @@ -0,0 +1,23 @@ +/** + * Prepends a fixed high-entropy sync preamble to a bitstream. + * + * @param payloadBits - Bit array of 0/1 values to append after the preamble. + * @returns A new Uint8Array containing the preamble followed by `payloadBits`. + */ + +export function injectPreamble(payloadBits: Uint8Array) { + const PREAMBLE = new Uint8Array([ + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, + 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, + 1, 0, 1, 1, 1, + ]); + + const totalBits = PREAMBLE.length + payloadBits.length; + const syncStream = new Uint8Array(totalBits); + + syncStream.set(PREAMBLE, 0); + syncStream.set(payloadBits, PREAMBLE.length); + + return syncStream; +} diff --git a/src/core/embedding/bitstream/serialiser.ts b/src/core/embedding/bitstream/serialiser.ts new file mode 100644 index 0000000..de36ff6 --- /dev/null +++ b/src/core/embedding/bitstream/serialiser.ts @@ -0,0 +1,30 @@ +/** + * Serializes an array of byte shards into a flat bit array. + * + * Each input shard is processed in order and each byte is expanded into eight elements + * containing `0` or `1`. Bits for a single byte are emitted from least-significant to + * most-significant (LSB first). + * + * @param interleadShards - Array of `Uint8Array` shards whose bytes will be unpacked into bits + * @returns A `Uint8Array` of `0`/`1` values representing the concatenated bits of all input bytes + */ + +export function serialiseBits(interleadShards: Uint8Array[]): Uint8Array { + const totalBytes = interleadShards.reduce( + (acc, shard) => acc + shard.length, + 0, + ); + const bitstream = new Uint8Array(totalBytes * 8); + + let bitIndex = 0; + for (const shard of interleadShards) { + for (let i = 0; i < shard.length; i++) { + const byte = shard[i]; + + for (let shift = 0; shift < 8; shift++) { + bitstream[bitIndex++] = (byte! >> shift) & 1; + } + } + } + return bitstream; +} diff --git a/src/core/embedding/crypto/aes.ts b/src/core/embedding/crypto/aes.ts new file mode 100644 index 0000000..0770c69 --- /dev/null +++ b/src/core/embedding/crypto/aes.ts @@ -0,0 +1,23 @@ +import { createCipheriv, randomBytes } from "node:crypto"; + +/** + * Encrypts a payload using AES-256-GCM and returns a single packet containing the nonce, ciphertext, and authentication tag. + * + * @param framedPayload - Plaintext bytes to encrypt. + * @param key - 32-byte AES-256 key. + * @returns A Uint8Array structured as [nonce (12 bytes) | ciphertext | authTag (16 bytes)]. + */ + +export function encryptPayload(framedPayload: Uint8Array, key: Uint8Array) { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + + const cipherText = Buffer.concat([ + cipher.update(framedPayload), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + const encryptedPacket = Buffer.concat([nonce, cipherText, authTag]); + return new Uint8Array(encryptedPacket); +} diff --git a/src/core/embedding/crypto/keyDerivation.ts b/src/core/embedding/crypto/keyDerivation.ts new file mode 100644 index 0000000..c7fbe71 --- /dev/null +++ b/src/core/embedding/crypto/keyDerivation.ts @@ -0,0 +1,31 @@ +import { pbkdf2 } from "node:crypto"; +import { promisify } from "node:util"; + +const pbkdf2Async = promisify(pbkdf2); + +/** + * Derives a 256-bit cryptographic key from a password using PBKDF2. + * + * @param password - The human-readable password to derive the key from. + * @param salt - A cryptographic salt (unique and unpredictable) used during derivation. + * @returns The derived 32-byte (256-bit) key as a `Uint8Array`. + * @throws If key derivation fails. + */ +export async function deriveKey(password: string, salt: Uint8Array) { + const iterations = 600_000; + const keyLength = 32; + const digest = "sha256"; + + try { + const derivedKey = await pbkdf2Async( + password, + salt, + iterations, + keyLength, + digest, + ); + return new Uint8Array(derivedKey); + } catch (error) { + throw new Error("Failed to derive key"); + } +} diff --git a/src/core/embedding/fec/interleave.ts b/src/core/embedding/fec/interleave.ts new file mode 100644 index 0000000..7434089 --- /dev/null +++ b/src/core/embedding/fec/interleave.ts @@ -0,0 +1,31 @@ +/** + * Distributes input shards so that shards from the same Reed–Solomon block are separated in the output order. + * + * @param shards - Input shard buffers in their original (block-consecutive) order. + * @param dataShards - Number of data shards per Reed–Solomon block. + * @param parityShards - Number of parity shards per Reed–Solomon block. + * @returns The same shard buffers reordered into an interleaved array so consecutive physical errors affect different RS blocks. + */ + +export function interleave( + shards: Uint8Array[], + dataShards: number, + parityShards: number, +) { + const totalShardsPerBlock = dataShards + parityShards; + const numBlocks = Math.ceil(shards.length / totalShardsPerBlock); + const interleaved: Uint8Array[] = new Array(shards.length); + + let index = 0; + for (let col = 0; col < totalShardsPerBlock; col++) { + for (let row = 0; row < numBlocks; row++) { + const sourceIdx = row * totalShardsPerBlock + col; + + if (sourceIdx < shards.length) { + interleaved[index++] = shards[sourceIdx]!; + } + } + } + + return interleaved; +} diff --git a/src/core/embedding/fec/readSolomon.ts b/src/core/embedding/fec/readSolomon.ts new file mode 100644 index 0000000..a2d390b --- /dev/null +++ b/src/core/embedding/fec/readSolomon.ts @@ -0,0 +1,86 @@ +import { ReedSolomonErasure } from "@subspace/reed-solomon-erasure.wasm"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +let rsInstance: ReedSolomonErasure | null = null; + +/** + * Lazily initializes and returns a singleton ReedSolomonErasure engine loaded from the package's WASM binary. + * + * Loads "reed_solomon_erasure_bg.wasm" from the installed @subspace/reed-solomon-erasure.wasm package, caches the created instance, and reuses it for subsequent calls. Throws an Error if the wasm file is not found at the expected location. + * + * @returns A cached or newly created ReedSolomonErasure instance + */ +async function getRSEngine(): Promise { + if (!rsInstance) { + const pkgPath = require.resolve("@subspace/reed-solomon-erasure.wasm"); + const pkgDir = path.dirname(pkgPath); + + const wasmPath = path.join(pkgDir, "reed_solomon_erasure_bg.wasm"); + if (!fs.existsSync(wasmPath)) { + throw new Error(`WASM file missing! Looked for it at: ${wasmPath}`); + } + + const wasmBuffer = fs.readFileSync(wasmPath); + rsInstance = ReedSolomonErasure.fromBytes(wasmBuffer); + } + return rsInstance; +} + +/** + * Encode an array of packets into data and parity shards using Reed–Solomon FEC. + * + * Encodes the input packets in sequential blocks of `dataShards`, producing `dataShards + parityShards` + * fixed-size shards per block and returning them as a flat array (data shards followed by parity shards + * for each block). + * + * @param packets - Array of equal-length `Uint8Array` packets to encode. An empty array returns an empty result. + * @param dataShards - Number of data shards per block. + * @param parityShards - Number of parity shards to produce per block (default: 3). + * @returns A flat `Uint8Array[]` containing all encoded shards; for each input block the first `dataShards` + * are the original data shards (padded as needed) followed by `parityShards` parity shards. + * @throws Error if the underlying WASM Reed–Solomon encoder returns a non-OK result. + */ + +export async function applyFEC( + packets: Uint8Array[], + dataShards: number, + parityShards: number = 3, +): Promise { + if (packets.length === 0) return []; + + const rs = await getRSEngine(); + const shardLength = packets[0]!.length; + const encodedStream: Uint8Array[] = []; + + for (let i = 0; i < packets.length; i += dataShards) { + const block = packets.slice(i, i + dataShards); + + const totalShards = dataShards + parityShards; + const contiguousBuffer = new Uint8Array(totalShards * shardLength); + + for (let j = 0; j < dataShards; j++) { + if (block[j]) { + contiguousBuffer.set(block[j]!, j * shardLength); + } + } + + const result = rs.encode(contiguousBuffer, dataShards, parityShards); + + if (result !== ReedSolomonErasure.RESULT_OK) { + throw new Error(`WASM FEC Encoding failed with internal code: ${result}`); + } + + for (let j = 0; j < totalShards; j++) { + const shard = contiguousBuffer.slice( + j * shardLength, + (j + 1) * shardLength, + ); + encodedStream.push(shard); + } + } + + return encodedStream; +} diff --git a/src/core/embedding/generator.ts b/src/core/embedding/generator.ts new file mode 100644 index 0000000..c213262 --- /dev/null +++ b/src/core/embedding/generator.ts @@ -0,0 +1,41 @@ +import { randomBytes } from "node:crypto"; +import { framePayload } from "./payload/framing.js"; +import { loadFileToUint8 } from "./payload/Uint8FileReader.js"; +import { deriveKey } from "./crypto/keyDerivation.js"; +import { encryptPayload } from "./crypto/aes.js"; +import { packetize } from "./payload/packer.js"; +import { applyFEC } from "./fec/readSolomon.js"; +import { interleave } from "./fec/interleave.js"; +import { serialiseBits } from "./bitstream/serialiser.js"; +import { injectPreamble } from "./bitstream/preamble.js"; + +/** + * Prepare a file payload for transmission by framing, encrypting, packetizing, applying FEC and interleaving, serialising to bits, and injecting a preamble. + * + * @param filename - Path to the input file whose contents will be embedded and transmitted + * @param password - Password used to derive the encryption key for the payload + * @returns An object with: + * - `finalBitStream`: the prepared bitstream ready for transmission + * - `salt`: the 16-byte salt used for key derivation + */ +export async function preparePayload(filename: string, password: string) { + const rawBytes = await loadFileToUint8(filename); + const framed = framePayload(rawBytes, filename); + + const salt = randomBytes(16); + const key = await deriveKey(password, salt); + + const encrypted = encryptPayload(framed, key); + + const packets = packetize(encrypted, 256); + + const FEC_SHARDS = await applyFEC(packets, 6, 3); + + const interleaved = interleave(FEC_SHARDS, 6, 3); + + const payloadBits = serialiseBits(interleaved); + + const finalBitStream = injectPreamble(payloadBits); + + return { finalBitStream, salt }; +} diff --git a/src/core/embedding/payload/Uint8FileReader.ts b/src/core/embedding/payload/Uint8FileReader.ts new file mode 100644 index 0000000..98e56cb --- /dev/null +++ b/src/core/embedding/payload/Uint8FileReader.ts @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +/** + * Load a file from the project's "data" directory and return its contents as a Uint8Array. + * + * @param filename - Name of the file located inside the project's "data" directory + * @returns A Uint8Array view over the file's contents + * @throws Error if the file cannot be read (message: "Failed to read file: ") + */ +export async function loadFileToUint8(filename: string): Promise { + const filepath = path.join(process.cwd(), "data", filename); + try { + const buffer = await readFile(filepath); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } catch (error) { + throw new Error(`Failed to read file: ${filepath}`); + } +} diff --git a/src/core/embedding/payload/framing.ts b/src/core/embedding/payload/framing.ts new file mode 100644 index 0000000..181bf65 --- /dev/null +++ b/src/core/embedding/payload/framing.ts @@ -0,0 +1,33 @@ +/** + * Wraps file bytes and filename with a 10-byte protocol header to produce a single packet. + * + * The resulting packet layout is: 10-byte header, filename bytes, then file bytes. + * + * @param filebytes - Raw file data to place into the packet + * @param filename - File name which will be UTF-8 encoded and included immediately after the header (encoded length must be <= 255) + * @returns A Uint8Array containing the header followed by the filename bytes and the file bytes + * @throws Error when the UTF-8 encoded `filename` is longer than 255 bytes + */ + +export function framePayload(filebytes: Uint8Array, filename: string) { + const encoder = new TextEncoder(); + const filenameBytes = encoder.encode(filename); + + if (filenameBytes.length > 255) { + throw new Error("Filename too long"); + } + + const headerSize = 10; + const totalSize = headerSize + filenameBytes.length + filebytes.length; + const packet = new Uint8Array(totalSize); + const view = new DataView(packet.buffer); + + view.setUint32(4, 0x44484944, false); + view.setUint32(4, 0x01); + view.setUint8(5, filenameBytes.length); + view.setUint32(6, filebytes.length, false); + + packet.set(filenameBytes, headerSize); + packet.set(filebytes, headerSize + filenameBytes.length); + return packet; +} diff --git a/src/core/embedding/payload/packer.ts b/src/core/embedding/payload/packer.ts new file mode 100644 index 0000000..ac43bf8 --- /dev/null +++ b/src/core/embedding/payload/packer.ts @@ -0,0 +1,35 @@ +/** + * Breaks encrypted data into sequential frames, each prefixed with a 4-byte big-endian frame identifier. + * + * @param encryptedData - The encrypted payload to split into frames. + * @param frameSize - Maximum number of payload bytes per frame (default: 512). The produced frame length is 4 + chunk length. + * @returns An array of `Uint8Array` frames; each frame starts with a 4-byte big-endian frameId (starting at 0) followed by a slice of the input data. + */ + +export function packetize( + encryptedData: Uint8Array, + frameSize: number = 512, +): Uint8Array[] { + const frames: Uint8Array[] = []; + const totalBytes = encryptedData.length; + + let offset = 0; + let frameId = 0; + + while (offset < totalBytes) { + const end = Math.min(offset + frameSize, totalBytes); + const chunk = encryptedData.slice(offset, end); + + const frame = new Uint8Array(4 + chunk.length); + const view = new DataView(frame.buffer); + + view.setUint32(0, frameId, false); + frame.set(chunk, 4); + frames.push(frame); + + offset += frameSize; + frameId++; + } + + return frames; +} diff --git a/src/core/profiler/freqBarkMap.ts b/src/core/profiler/freqBarkMap.ts index 667bf5e..3974c2c 100644 --- a/src/core/profiler/freqBarkMap.ts +++ b/src/core/profiler/freqBarkMap.ts @@ -23,11 +23,17 @@ export function computeBarkEnergy(powerSpectrum: Float32Array): Float32Array { return barkEnergy; } +/** + * Identify FFT bin indices whose power is below their corresponding Bark-band threshold. + * + * @param powerSpectrum - Power values for each FFT bin. + * @param thresholds - Threshold values for each Bark band (indexed by band); used to compare against each bin's power. + * @returns An array of FFT bin indices where the bin power is less than its Bark-band threshold. + */ export function identifySafeBins( powerSpectrum: Float32Array, thresholds: Float32Array, ) { - // let count = 0; const result: number[] = []; for (let i = 0; i < powerSpectrum.length; i++) { @@ -37,7 +43,6 @@ export function identifySafeBins( if (binPower < bandThreshold) { result.push(i); - // count++; } } return result; diff --git a/src/core/profiler/recorder.ts b/src/core/profiler/recorder.ts index 71c4b58..1f95d24 100644 --- a/src/core/profiler/recorder.ts +++ b/src/core/profiler/recorder.ts @@ -2,9 +2,17 @@ import { PvRecorder } from "@picovoice/pvrecorder-node"; import { buffer } from "../../types/AudioRingBuffer.js"; import { processSTFT } from "./processFrame.js"; -export async function recorder() { +/** + * Continuously captures microphone audio, analyzes frames for safe frequency bins, and advances through the provided bitstream when safe bins are found. + * + * The function pushes captured frames into a shared audio buffer, computes a masking map from the buffered audio, and consumes bits from `bitstream` corresponding to detected safe bins while logging progress. The recorder is stopped on error and always released when finished. + * + * @param bitstream - Sequence of bits to consume/inject when safe frequency bins are detected in the captured audio + */ +export async function recorder(bitstream: Uint8Array) { const frameSize = 512; const pvRecorder = new PvRecorder(frameSize, -1); + let bitPtr = 0; pvRecorder.start(); @@ -15,15 +23,28 @@ export async function recorder() { if (buffer.size >= 1024) { const maskingMap = processSTFT(buffer); + if (maskingMap.length > 0) { + const latestFrame = maskingMap[maskingMap.length - 1]!; + const safebins = latestFrame.safeBins; + + if (safebins.length > 0 && bitPtr < bitstream.length) { + for (const bitIndex of safebins) { + if (bitPtr >= bitstream.length) break; + + const currentBit = bitstream[bitPtr]; + + bitPtr++; + } + } + console.log( - "Processed Frame Index:", - maskingMap[maskingMap.length - 1]!.frameIndex, - ); - console.log( - "Safe Bins:", - maskingMap[maskingMap.length - 1]!.safeBins.length, + `Frame: ${latestFrame.frameIndex} | Safe Bins: ${safebins.length} | Progress: ${bitPtr}/${bitstream.length} bits`, ); + if (bitPtr >= bitstream.length) { + console.log("SUCCESS! entire bitstream injected"); + // break; + } } } } diff --git a/src/main.ts b/src/main.ts index 1907e9f..3b661a6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,24 @@ +import { preparePayload } from "./core/embedding/generator.js"; import { recorder } from "./core/profiler/recorder.js"; import { initialise } from "./utils/initialise.js"; -try { - initialise(); +/** + * Initializes the application, prepares a bit-stream payload from a file, and records the resulting stream. + * + * This function calls the initialization routine, prepares the payload (logging "Preparing Bit Stream..."), passes the resulting `finalBitStream` to the recorder, and catches any errors, logging them to the console. + */ +async function start() { + try { + initialise(); + console.log("Preparing Bit Stream..."); + const { finalBitStream } = await preparePayload("file.txt", "1234"); - await recorder(); -} catch (error: unknown) { - console.error(error); + await recorder(finalBitStream); + } catch (error: unknown) { + console.error(error); + } } process.on("SIGINT", () => process.exit()); + +await start();