Skip to content
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"type": "module",
"main": "src/main.ts",
"scripts": {
"dev": "nodemon"
"build": "tsc",
"dev": "npm run build && nodemon"
},
"devDependencies": {
"@types/node": "^25.3.3",
Expand Down
22 changes: 22 additions & 0 deletions src/core/modulator/dsss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* DSSS Spreader
* Multiplies a bipolar symbol by a PN sequence.
*/

export class DSSS_Spreader {
/**
* Spreads a symbol into a chip array.
* @param symbol bipolar symbol
* @param pnSequence 64-chip PN sequence
* @returns Float32Array of 64 spread chips
*/

static spread(symbol: number, pnSequence: Float32Array): Float32Array {
const spreadChips = new Float32Array(pnSequence.length);

for (let i = 0; i < pnSequence.length; i++)
spreadChips[i] = pnSequence[i]! * symbol;

return spreadChips;
}
}
42 changes: 42 additions & 0 deletions src/core/modulator/embedChips.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Spectral Embedding
* Injects DSSS chips into the FFT spectrum.
*/

export function embedFrameChips(
fftComplex: Float32Array,
chipMap: Map<number, number>,
N: number,
delta: number = 0.02,
) {
Comment on lines +6 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: fftComplex receives power spectrum instead of interleaved complex array.

According to src/core/profiler/fft.ts, processFFT returns powerSpectrum (a magnitude-only Float32Array of length FRAME_SIZE/2 = 512), not the interleaved complex array [real0, imag0, real1, imag1, ...] that this function expects.

In processFrame.ts line 32, const fftComplex = processFFT(frames) passes this power spectrum to embedFrameChips. This causes:

  1. Out-of-bounds access: rIndex = binIndex * 2 can exceed the 512-element array
  2. Incorrect data interpretation: Magnitude values are read as real/imaginary pairs
  3. Silent corruption: Phase calculations on magnitude values produce garbage
🔧 Fix: Return the complex FFT array from processFFT

In src/core/profiler/fft.ts, export the complex array alongside or instead of power spectrum:

 export function processFFT(windowedFrame: Float32Array) {
   f.realTransform(out, windowedFrame);
+  f.completeSpectrum(out);  // If needed for full spectrum
   const powerSpectrum = new Float32Array(FRAME_SIZE / 2);

   for (let i = 0; i < FRAME_SIZE / 2; i++) {
     const real = out[2 * i]!;
     const imag = out[2 * i + 1]!;
     powerSpectrum[i] = real * real + imag * imag;
   }

-  return powerSpectrum;
+  return { complex: new Float32Array(out), powerSpectrum };
 }

Then update callers to use the appropriate field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/modulator/embedChips.ts` around lines 6 - 11, embedFrameChips
expects an interleaved complex FFT array (real,imag pairs) but processFFT
currently returns a powerSpectrum (magnitude-only) which causes out-of-bounds
and wrong-phase calculations; fix by changing processFFT in
src/core/profiler/fft.ts to return (or additionally export) the interleaved
complex Float32Array used to compute powerSpectrum, then update the call site in
processFrame.ts (where fftComplex is assigned) to pass the interleaved complex
array to embedFrameChips (or adjust embedFrameChips to accept magnitude-only and
remove the rIndex/cIndex logic); ensure the returned array length matches the
expected interleaved size (FRAME_SIZE or 2*FRAME_SIZE/2) and update
types/variable names accordingly so embedFrameChips reads real/imag pairs
correctly.

const modifiedFFT = new Float32Array(fftComplex);

for (const [binIndex, chipValue] of chipMap.entries()) {
const rIndex = binIndex * 2;
const iIndex = rIndex + 1;

const real = fftComplex[rIndex]!;
const imag = fftComplex[iIndex]!;
const magnitude = Math.sqrt(real * real + imag * imag);
const originalPhase = Math.atan2(imag, real);

const newPhase = originalPhase + (chipValue + delta);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Phase modification formula may not match intended design.

The phase update originalPhase + (chipValue + delta) adds the chip value (+1 or -1) and delta (0.02), resulting in phase shifts of ~+1.02 or ~-0.98 radians.

The PR description mentions "delta = 0.02 rad" for phase rotation. If the intent is a small phase perturbation modulated by the chip sign, consider:

-    const newPhase = originalPhase + (chipValue + delta);
+    const newPhase = originalPhase + (chipValue * delta);

This would produce ±0.02 rad shifts, which is sub-perceptual as described.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const newPhase = originalPhase + (chipValue + delta);
const newPhase = originalPhase + (chipValue * delta);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/modulator/embedChips.ts` at line 23, The current update in
embedChips.ts computes newPhase as originalPhase + (chipValue + delta) which
incorrectly adds the chip sign and delta producing ~±1.02 rad shifts; change the
formula to apply the small rotation scaled by the chip sign (e.g., newPhase =
originalPhase + chipValue * delta) so chipValue (±1) only selects the ±0.02 rad
perturbation; update any tests/uses of newPhase/originalPhase accordingly to
reflect the smaller ±delta rotation.


const newReal = magnitude * Math.cos(newPhase);
const newImag = magnitude * Math.sin(newPhase);

modifiedFFT[rIndex] = newReal;
modifiedFFT[iIndex] = newImag;

const mirrorBin = N - binIndex;
if (mirrorBin > binIndex && mirrorBin < N) {
const mirrorRIndex = mirrorBin * 2;
const mirrorIIndex = mirrorRIndex + 1;

modifiedFFT[mirrorRIndex] = newReal;
modifiedFFT[mirrorIIndex] = -newImag;
}
Comment on lines +31 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Mirror bin index calculation is out-of-bounds for real FFT.

For a real FFT of size N=1024, valid bin indices are 0 to 511 (FRAME_SIZE/2). The formula mirrorBin = N - binIndex computes indices like 1023, 1022, etc., which exceed the valid range.

For Hermitian symmetry with fft.js's realTransform, the conjugate bin for index k in [1, N/2-1] is at index N/2 - k (or you work with the full N complex array after calling completeSpectrum).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/modulator/embedChips.ts` around lines 31 - 38, The mirror bin
calculation is producing out-of-range indices (mirrorBin = N - binIndex); change
it to use the half-size (halfN = N/2) and compute mirrorBin = halfN - binIndex
(and only apply for binIndex in 1..halfN-1) so Hermitian symmetry matches fft.js
realTransform expectations; keep using modifiedFFT with mirrorRIndex = mirrorBin
* 2 and mirrorIIndex = mirrorRIndex + 1 and set modifiedFFT[mirrorRIndex] =
newReal and modifiedFFT[mirrorIIndex] = -newImag (or skip mirror assignment for
DC and Nyquist bins).

}

return modifiedFFT;
}
39 changes: 39 additions & 0 deletions src/core/modulator/mapping/binMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Safe Bin Mapping
*
* Maps chips to safe FFT bins using the PN-based shuffle
*/

export class BinMapper {
/**
* Assigns 64 chips to a randomized subset of safe bins.
* @param chips 64 spread chips
* @param safeBins List of indices
* @param seed Frame-specific or shared seed for shuffling
*/

static mapToBins(
chips: Float32Array,
safeBins: number[],
seed: number,
): Map<number, number> {
const map = new Map<number, number>();

const shuffledBins = [...safeBins];
let tempSeed = seed;

for (let i = shuffledBins.length - 1; i > 0; i--) {
tempSeed = (tempSeed * 1680721) % 2147483647;
const j = tempSeed % (i + 1);

[shuffledBins[i], shuffledBins[j]] = [shuffledBins[j]!, shuffledBins[i]!];
}

for (let i = 0; i < chips.length; i++) {
const binIndex = shuffledBins[i % shuffledBins.length]!;
map.set(binIndex, chips[i]!);
}

return map;
}
}
39 changes: 39 additions & 0 deletions src/core/modulator/pnGen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* PN Sequence Generator (LFSR)
* Generates a deterministic maximal-length pseudo-random sequence (PN).
*/

export class PNGenerator {
private state: number;
private readonly mask: number = 0xb400;

constructor(seed: number = 0xace1) {
this.state = (seed === 0 ? 0xace1 : seed) & 0xffff;
}

/**
* Generates a single chip using the LFSR.
* @returns +1 or -1
*/

private nextChip(): number {
const lsb = this.state & 1;
this.state >>> 1;

if (lsb === 1) this.state ^= this.mask;
return lsb === 1 ? 1 : -1;
}
Comment on lines +19 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: LFSR shift result is not assigned, producing a degenerate constant sequence.

Line 21 this.state >>> 1 computes the right-shift but discards the result. The state never advances, causing the LFSR to output a constant or 2-state sequence instead of the expected maximal-length PN sequence.

This completely breaks the DSSS spreading—all chips become identical, eliminating the processing gain.

🐛 Fix: Assign the shift result
 private nextChip(): number {
   const lsb = this.state & 1;
-  this.state >>> 1;
+  this.state = this.state >>> 1;

   if (lsb === 1) this.state ^= this.mask;
   return lsb === 1 ? 1 : -1;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private nextChip(): number {
const lsb = this.state & 1;
this.state >>> 1;
if (lsb === 1) this.state ^= this.mask;
return lsb === 1 ? 1 : -1;
}
private nextChip(): number {
const lsb = this.state & 1;
this.state = this.state >>> 1;
if (lsb === 1) this.state ^= this.mask;
return lsb === 1 ? 1 : -1;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/modulator/pnGen.ts` around lines 19 - 25, The LFSR in nextChip()
never advances because the right-shift result is discarded; change the shift to
assign back to the state (e.g., use an unsigned right-shift assignment on
this.state) so the register actually moves before applying the feedback mask;
ensure the code still tests lsb from the old state, then if (lsb === 1) apply
this.state ^= this.mask to inject feedback, so nextChip() produces the intended
PN sequence.


/**
* Generates the Spreading Sequence
* @param length The spreading factor
*/

public generateSequence(length: number): Float32Array {
const sequence = new Float32Array(length);
for (let i = 0; i < length; i++) {
sequence[i] = this.nextChip();
}
return sequence;
}
}
55 changes: 55 additions & 0 deletions src/core/modulator/spreader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* DSSS Spreading Algorithm
* Transforms bits into a spreading signal (chips) for injection
*/

export class Spreader {
private readonly SF: number = 64;
private chipIndex: number = 0;
private currentBit: number | null = null;
private pnSequence: Int8Array;

constructor(seed: number = 0xaec2) {
this.pnSequence = this.generatePN(1024, seed);
}

private generatePN(length: number, seed: number): Int8Array {
const pn = new Int8Array(length);
let lsfr = seed;

for (let i = 0; i < length; i++) {
lsfr ^= lsfr >> 7;
lsfr ^= lsfr << 9;
lsfr ^= lsfr >> 13;

pn[i] = (lsfr & 1) === 1 ? 1 : -1;
}
return pn;
}

/**
* @returns the next modulation chip for the bit stream
*/

public getNextChip(
bitstream: Uint8Array,
bitPtr: { index: number },
): number | null {
if (bitPtr.index >= bitstream.length) return null;

if (this.currentBit === 0)
this.currentBit = bitstream[bitPtr.index++] === 1 ? 1 : -1;

const pnChip = this.pnSequence[this.chipIndex % this.pnSequence.length]!;

const modulatedChip = this.currentBit! * pnChip;
this.chipIndex++;

if (this.chipIndex > this.SF) {
this.chipIndex = 0;
bitPtr.index++;
}

return modulatedChip;
}
}
40 changes: 31 additions & 9 deletions src/core/profiler/processFrame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { AudioRingBuffer } from "../../types/AudioRingBuffer.js";
import { DSSS_Spreader } from "../modulator/dsss.js";
import { embedFrameChips } from "../modulator/embedChips.js";
import { BinMapper } from "../modulator/mapping/binMapper.js";
import type { PNGenerator } from "../modulator/pnGen.js";
import { processFFT } from "./fft.js";
import { computeBarkEnergy, identifySafeBins } from "./freqBarkMap.js";
import { estimateMasking } from "./masking.js";
Expand All @@ -9,8 +13,13 @@ const FRAME_SIZE = 1024;

let frameCount = 0;

export function processSTFT(audioBuffer: AudioRingBuffer) {
const maskingMap = [];
export function processSTFT(
audioBuffer: AudioRingBuffer,
bitstream: Uint8Array,
bitPtr: { index: number },
pnGen: PNGenerator,
) {
const outSpectra = [];

while (audioBuffer.size >= FRAME_SIZE) {
const frames = audioBuffer.getFrames(FRAME_SIZE);
Expand All @@ -20,15 +29,25 @@ export function processSTFT(audioBuffer: AudioRingBuffer) {

console.log("Frame min/max:", Math.min(...frames), Math.max(...frames));

const powerSpectrum = processFFT(frames);
const fftComplex = processFFT(frames);
const bandEnergy = computeBarkEnergy(fftComplex);
const maskingThresholds = estimateMasking(bandEnergy);
const safeBins = identifySafeBins(fftComplex, maskingThresholds);
Comment on lines +32 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Type mismatch: processFFT returns power spectrum, not complex FFT array.

processFFT returns a magnitude-only Float32Array of length 512, but downstream code (embedFrameChips, computeBarkEnergy) may expect different formats. The variable name fftComplex is misleading.

See the related comment on embedChips.ts for the critical impact on spectral embedding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/profiler/processFrame.ts` around lines 32 - 35, The variable
fftComplex is misnamed and misleading because processFFT actually returns a
magnitude/power Float32Array (length 512) rather than a complex FFT; update the
code so downstream callers use the correct data type: either change processFFT
to return the complex FFT (if computeBarkEnergy, embedFrameChips,
identifySafeBins expect complex) or rename fftComplex to something like
powerSpectrum and convert/derive a complex representation before calling
functions that require complex input (or adapt computeBarkEnergy and
embedFrameChips to accept magnitude arrays). Specifically, adjust the call site
around processFFT, computeBarkEnergy, estimateMasking, identifySafeBins and the
functions embedFrameChips/computeBarkEnergy to agree on a single format
(magnitude vs complex) and update variable names (fftComplex -> powerSpectrum or
complexFFT) to reflect the chosen format.


const bandEnergy = computeBarkEnergy(powerSpectrum);
console.log(`Found ${safeBins.length} safe bins for data injection`);

const maskingThresholds = estimateMasking(bandEnergy);
let finalSpectrum = fftComplex;

const safeBins = identifySafeBins(powerSpectrum, maskingThresholds);
if (safeBins.length >= 64 && bitPtr.index < bitstream.length) {
const bit = bitstream[bitPtr.index]!;
const symbol = (bit << 1) - 1;
const pnSequence = pnGen.generateSequence(64);
const spreadChips = DSSS_Spreader.spread(symbol, pnSequence);

console.log(`Found ${safeBins.length} safe bins for data injection`);
const chipMap = BinMapper.mapToBins(spreadChips, safeBins, frameCount);
finalSpectrum = embedFrameChips(fftComplex, chipMap, FRAME_SIZE, 0.02);
bitPtr.index++;
}

const map = {
frameIndex: frameCount++,
Expand All @@ -37,10 +56,13 @@ export function processSTFT(audioBuffer: AudioRingBuffer) {
maskingThresholds: new Float32Array(maskingThresholds),
};

maskingMap.push(map);
outSpectra.push({
spectrum: finalSpectrum,
frameIndex: frameCount++,
});

audioBuffer.advance(HOP_SIZE);
}

return maskingMap;
return outSpectra;
}
51 changes: 31 additions & 20 deletions src/core/profiler/recorder.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { PvRecorder } from "@picovoice/pvrecorder-node";
import { buffer } from "../../types/AudioRingBuffer.js";
import { processSTFT } from "./processFrame.js";
import { PNGenerator } from "../modulator/pnGen.js";

export async function recorder(bitstream: Uint8Array) {
const frameSize = 512;
const pvRecorder = new PvRecorder(frameSize, -1);
let bitPtr = 0;
const pnGen = new PNGenerator(0xace1);
const bitPtr = { index: 0 };

pvRecorder.start();

Expand All @@ -15,30 +17,39 @@ export async function recorder(bitstream: Uint8Array) {
buffer.push(frames);

if (buffer.size >= 1024) {
const maskingMap = processSTFT(buffer);
const modifiedFrames = processSTFT(buffer, bitstream, bitPtr, pnGen);

if (maskingMap.length > 0) {
const latestFrame = maskingMap[maskingMap.length - 1]!;
const safebins = latestFrame.safeBins;
for (const frame of modifiedFrames)
console.log(
`Frame: ${frame.frameIndex} | Progress: ${bitPtr.index}/${bitstream.length}`,
);

if (safebins.length > 0 && bitPtr < bitstream.length) {
for (const bitIndex of safebins) {
if (bitPtr >= bitstream.length) break;
if (bitPtr.index >= bitstream.length) {
console.log("SUCCESS! Payload fully modulated into spectra.");
}

const currentBit = bitstream[bitPtr];
// if (maskingMap.length > 0) {
// const latestFrame = maskingMap[maskingMap.length - 1]!;
// const safebins = latestFrame.safeBins;

bitPtr++;
}
}
// if (safebins.length > 0 && bitPtr < bitstream.length) {
// for (const bitIndex of safebins) {
// if (bitPtr >= bitstream.length) break;

console.log(
`Frame: ${latestFrame.frameIndex} | Safe Bins: ${safebins.length} | Progress: ${bitPtr}/${bitstream.length} bits`,
);
if (bitPtr >= bitstream.length) {
console.log("SUCCESS! entire bitstream injected");
// break;
}
}
// const currentBit = bitstream[bitPtr];

// bitPtr++;
// }
// }

// console.log(
// `Frame: ${latestFrame.frameIndex} | Safe Bins: ${safebins.length} | Progress: ${bitPtr}/${bitstream.length} bits`,
// );
// if (bitPtr >= bitstream.length) {
// console.log("SUCCESS! entire bitstream injected");
// // break;
// }
// }
}
}
} catch (error: unknown) {
Expand Down