Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
build/
.env
data/
*.wav
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@picovoice/pvrecorder-node": "^1.2.8",
"@subspace/reed-solomon-erasure.wasm": "^0.2.5",
"chalk": "^5.6.2",
"fft.js": "^4.0.4"
"fft.js": "^4.0.4",
"wavefile": "^11.0.0"

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check latest wavefile version on npm
curl -s https://registry.npmjs.org/wavefile/latest | jq '.version'

# Check for security advisories
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: NPM, package: "wavefile") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: Forgata/deephide

Length of output: 229


🏁 Script executed:

#!/bin/bash
# Get full package info from npm registry
curl -s https://registry.npmjs.org/wavefile | jq '{
  "latest_version": .["dist-tags"].latest,
  "all_versions": (.versions | keys | sort | .[-5:]),
  "description": .description,
  "repository": .repository.url,
  "last_modified": .time.modified
}'

# Try to check for known vulnerabilities using npm advisory endpoint
curl -s https://registry.npmjs.org/-/npm/v1/security/advisories/bulk -X POST \
  -H "Content-Type: application/json" \
  -d '{"wavefile":["11.0.0"]}' 2>/dev/null | jq . || echo "Advisory endpoint not available"

Repository: Forgata/deephide

Length of output: 365


The wavefile package version 11.0.0 exists with no known vulnerabilities, but consider maintenance risk.

The package version exists on npm with no security advisories. However, wavefile hasn't been updated since May 2022 (3+ years old), which may pose a maintenance risk. Consider evaluating whether to use this unmaintained package or switch to an actively maintained alternative for WAV file handling.

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

In `@package.json` at line 33, The dependency "wavefile" in package.json is old
(last updated May 2022) and may be a maintenance risk; review its usage (search
for imports/references to "wavefile" in the codebase), decide whether to keep
it, pin a specific version, or replace it with an actively maintained
alternative (e.g., a maintained WAV library) and update package.json
accordingly, and add a short comment in the repo README or a dependencies.md
noting the decision and any rationale for choosing to keep or replace
"wavefile".

}
}
6 changes: 5 additions & 1 deletion src/core/modulator/embedChips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

export function embedFrameChips(
fftComplex: Float32Array,
fftComplex: Float32Array | number[],
chipMap: Map<number, number>,
N: number,
delta: number = 0.02,
Expand All @@ -15,6 +15,10 @@ export function embedFrameChips(
const rIndex = binIndex * 2;
const iIndex = rIndex + 1;

if (iIndex >= fftComplex.length) {
continue;
}

const real = fftComplex[rIndex]!;
const imag = fftComplex[iIndex]!;
const magnitude = Math.sqrt(real * real + imag * imag);
Expand Down
19 changes: 14 additions & 5 deletions src/core/profiler/fft.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import FFT from "fft.js";
const FRAME_SIZE = 1024;
const f = new FFT(FRAME_SIZE);
const out = f.createComplexArray();

/**
* Processes a windowed frame of audio samples using the Fast Fourier Transform (FFT)
* @param {Float32Array} windowedFrame - a windowed frame of audio samples
* @returns {Object} - an object containing the FFT complex array and power spectrum array
* @property {number[]} fftComplex - the FFT complex array
* @property {Float32Array} powerSpectrum - the power spectrum array
*/
export function processFFT(windowedFrame: Float32Array) {
f.realTransform(out, windowedFrame);
const fftComplex = f.createComplexArray();
f.realTransform(fftComplex, windowedFrame);
f.completeSpectrum(fftComplex);

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]!;
const real = fftComplex[2 * i]!;
const imag = fftComplex[2 * i + 1]!;

powerSpectrum[i] = real * real + imag * imag;
}

return powerSpectrum;
return { fftComplex, powerSpectrum };
}
36 changes: 20 additions & 16 deletions src/core/profiler/processFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,57 +10,61 @@ import { HAMMING_WINDOW } from "./window.js";

const HOP_SIZE = 512;
const FRAME_SIZE = 1024;

let frameCount = 0;
interface FrameState {
count: number;
}

export function processSTFT(
audioBuffer: AudioRingBuffer,
bitstream: Uint8Array,
bitPtr: { index: number },
pnGen: PNGenerator,
frameState: FrameState = { count: 0 },
) {
Comment on lines 17 to 23

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

Make frameState mandatory instead of defaulting it.

src/core/profiler/recorder.ts still calls processSTFT() without this argument, so the default { count: 0 } is recreated on every invocation. That resets frameIndex and keeps the BinMapper seed at 0, causing the same safe-bin shuffle to repeat across buffers instead of advancing with the stream.

Suggested change
 interface FrameState {
   count: number;
 }

 export function processSTFT(
   audioBuffer: AudioRingBuffer,
   bitstream: Uint8Array,
   bitPtr: { index: number },
   pnGen: PNGenerator,
-  frameState: FrameState = { count: 0 },
+  frameState: FrameState,
 ) {

Create one shared const frameState = { count: 0 }; in src/core/profiler/recorder.ts and pass it to every call.

🤖 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 17 - 23, The function
processSTFT currently defaults frameState to a new { count: 0 } each call which
resets frameIndex/PN seed; remove the default parameter so frameState is
required in processSTFT's signature and update all callers (notably the calls in
recorder.ts) to create one shared const frameState = { count: 0 } in recorder.ts
and pass that single object into every processSTFT(AudioRingBuffer, bitstream,
bitPtr, pnGen, frameState) invocation so the count advances across buffers and
the BinMapper seed progresses with the stream.

const outSpectra = [];

while (audioBuffer.size >= FRAME_SIZE) {
const frames = audioBuffer.getFrames(FRAME_SIZE);
const rawFrames = audioBuffer.getFrames(FRAME_SIZE);
const frames = new Float32Array(rawFrames);

for (let i = 0; i < FRAME_SIZE; i++)
frames[i] = frames[i]! * HAMMING_WINDOW[i]!;

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

const fftComplex = processFFT(frames);
const bandEnergy = computeBarkEnergy(fftComplex);
const { fftComplex, powerSpectrum } = processFFT(frames);
const bandEnergy = computeBarkEnergy(powerSpectrum);
const maskingThresholds = estimateMasking(bandEnergy);
const safeBins = identifySafeBins(fftComplex, maskingThresholds);
const safeBins = identifySafeBins(powerSpectrum, maskingThresholds);

console.log(`Found ${safeBins.length} safe bins for data injection`);

let finalSpectrum = fftComplex;
let finalSpectrum: Float32Array = new Float32Array(fftComplex);

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);

const chipMap = BinMapper.mapToBins(spreadChips, safeBins, frameCount);
const chipMap = BinMapper.mapToBins(
spreadChips,
safeBins,
frameState.count,
);
finalSpectrum = embedFrameChips(fftComplex, chipMap, FRAME_SIZE, 0.02);
bitPtr.index++;
}

const map = {
frameIndex: frameCount++,
safeBins,
bandEnergy: new Float32Array(bandEnergy),
maskingThresholds: new Float32Array(maskingThresholds),
};
const currentFrameIndex = frameState.count++;

outSpectra.push({
spectrum: finalSpectrum,
frameIndex: frameCount++,
frameIndex: currentFrameIndex,
safeBins,
bandEnergy: new Float32Array(bandEnergy),
maskingThresholds: new Float32Array(maskingThresholds),
});

audioBuffer.advance(HOP_SIZE);
}

Expand Down
38 changes: 14 additions & 24 deletions src/core/profiler/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { PvRecorder } from "@picovoice/pvrecorder-node";
import { buffer } from "../../types/AudioRingBuffer.js";
import { processSTFT } from "./processFrame.js";
import { PNGenerator } from "../modulator/pnGen.js";
import { processIFFT } from "../recombination/ifft.js";
import { overlapAdd } from "../recombination/reconstructor.js";
import { floatToInt16 } from "../recombination/pcmConverter.js";
import { collectOutput, saveWAV } from "../recombination/writer.js";

export async function recorder(bitstream: Uint8Array) {
const frameSize = 512;
Expand All @@ -19,37 +23,23 @@ export async function recorder(bitstream: Uint8Array) {
if (buffer.size >= 1024) {
const modifiedFrames = processSTFT(buffer, bitstream, bitPtr, pnGen);

for (const frame of modifiedFrames)
for (const frame of modifiedFrames) {
console.log(
`Frame: ${frame.frameIndex} | Progress: ${bitPtr.index}/${bitstream.length}`,
);

const timeframe = processIFFT(frame.spectrum);
const synthFrame = overlapAdd(timeframe);
const pcmFrame = floatToInt16(synthFrame);

collectOutput(pcmFrame);
Comment on lines +31 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 | 🟠 Major

Module-level state is not reset between recording sessions.

Both overlapAdd (in reconstructor.ts) and collectOutput/saveWAV (in writer.ts) use module-level state (overlapBuffer and pcmAccumulator) that persists across calls to recorder(). If this function is invoked multiple times:

  1. overlapBuffer retains samples from the previous session, corrupting the first output frame(s)
  2. pcmAccumulator accumulates indefinitely (the reset on line 20 of writer.ts is commented out), causing subsequent WAV files to contain all prior recordings

Consider adding and calling reset functions at the start of recorder() or after saveWAV().

🛠️ Suggested approach

Add reset functions to both modules and call them appropriately:

In reconstructor.ts:

export function resetOverlapBuffer(): void {
  overlapBuffer.fill(0);
}

In writer.ts:

export function resetAccumulator(): void {
  pcmAccumulator = [];
}

Then in recorder.ts, import and call these at the start:

 export async function recorder(bitstream: Uint8Array) {
+  resetOverlapBuffer();
+  resetAccumulator();
   const frameSize = 512;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/profiler/recorder.ts` around lines 31 - 35, The recorder() function
should reset module-level state before starting a new session to avoid
cross-session contamination: add and export functions like resetOverlapBuffer()
in reconstructor.ts (to clear overlapBuffer) and resetAccumulator() in writer.ts
(to reinitialize pcmAccumulator), then import and call resetOverlapBuffer() and
resetAccumulator() at the start of recorder() (before calling
processIFFT/overlapAdd/collectOutput) and/or immediately after saveWAV() to
ensure overlapAdd and collectOutput/saveWAV don’t carry state between
recordings.

}

if (bitPtr.index >= bitstream.length) {
console.log("SUCCESS! Payload fully modulated into spectra.");
saveWAV("output.wav");
pvRecorder.stop();
}

// 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(
// `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
27 changes: 27 additions & 0 deletions src/core/recombination/ifft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import FFT from "fft.js";

const FRAME_SIZE = 1024;
const f = new FFT(FRAME_SIZE);

const outputGain = 0.5;

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 | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

fft.js npm package inverseTransform normalization

💡 Result:

In the fft.js npm package (Fedor Indutny’s fft.js), inverseTransform(out, data) already applies normalization: after running the inverse FFT, it divides every element in the complex output buffer by this.size (i.e., 1/N scaling, applied to both real and imaginary interleaved slots). So transform() is unscaled, and inverseTransform() is scaled by 1/N. [1][2]

Implications:

  • inverseTransform(transform(x)) returns (approximately) the original signal without you doing extra scaling. [1][2]
  • If you want an unnormalized inverse, multiply the inverseTransform output by N (or alternatively divide the forward transform by N). [2]

Sources:
[1] fft.js README usage/API (shows inverseTransform as the inverse FFT entry point) (github.com)
[2] fft.js/lib/fft.js implementation of inverseTransform (out[i] /= this.size) (github.com)

Citations:


🏁 Script executed:

cat src/core/recombination/ifft.ts

Repository: Forgata/deephide

Length of output: 781


Add documentation explaining the outputGain = 0.5 scaling factor.

The hardcoded gain value lacks context. Since fft.js's inverseTransform() already applies 1/N normalization (dividing by 1024), the additional 0.5 multiplier results in total scaling of 1/2048 on the real output. This combined scaling is unusual and should be documented: explain whether it's intentional signal attenuation, dynamic range control, or clipping prevention, and justify why this specific value was chosen.

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

In `@src/core/recombination/ifft.ts` at line 6, The constant outputGain = 0.5
lacks context: update the code comments near the outputGain declaration in
src/core/recombination/ifft.ts to explain why the extra 0.5 scaling is applied
on top of fft.js's inverseTransform() (which already applies 1/N), stating
whether this is intentional attenuation, dynamic range control, or clipping
prevention, and justify the specific value (e.g., empirical clipping reduction,
headroom requirements, or matching downstream expected amplitude); reference the
inverseTransform() normalization and mention the resulting net scaling (1/2048)
and any tests/measurements used to choose 0.5 so future maintainers can
understand and adjust outputGain if needed.


/**
* Converts the frequency domain into the time domain PCM samples
* @param complexSpectrum interleaved Float32Array
* @returns real-valued Float32Array (PCM)
*/

export function processIFFT(complexSpectrum: Float32Array): Float32Array {
const outTimeDomain = f.createComplexArray();
// const realOutput = new Float32Array(outTimeDomain);
const realOutput = new Float32Array(FRAME_SIZE);

f.inverseTransform(outTimeDomain, complexSpectrum);

for (let i = 0; i < FRAME_SIZE; i++) {
const amplitude = outTimeDomain[i * 2] * outputGain;
realOutput[i] = amplitude;
}

return realOutput;
}
19 changes: 19 additions & 0 deletions src/core/recombination/pcmConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Converts Float32 audio samples to Int16 PCM
* Clamps values to prevent wrap-around distortion.
*/

export function floatToInt16(floatFrame: Float32Array): Int16Array {
const intFrame = new Int16Array(floatFrame.length);

for (let i = 0; i < floatFrame.length; i++) {
let s = floatFrame[i]! * 32767.0;

if (s > 32767.0) s = 32767.0;
if (s < -32768.0) s = -32768.0;

intFrame[i] = Math.round(s);
}

return intFrame;
}
Comment on lines +6 to +19

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 | 🟡 Minor

Input range assumption may cause hard clipping.

This function assumes floatFrame values are in the range [-1.0, 1.0]. Based on the upstream pipeline (IFFT with outputGain=0.5 and overlap-add dividing by 1.08), the effective gain is ~0.463, but the raw IFFT output magnitude depends on the input spectrum and is not inherently normalized.

If the modified spectrum produces time-domain values exceeding ±1.0 after scaling, clamping at lines 12-13 will hard-clip the audio, causing audible distortion. Consider adding a normalization step upstream or implementing peak detection to scale the entire buffer before conversion.

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

In `@src/core/recombination/pcmConverter.ts` around lines 6 - 19, The floatToInt16
function assumes input is within [-1,1] which can cause hard clipping; modify
floatToInt16 to first detect the peak absolute sample (maxAbs) across floatFrame
and, if maxAbs > 1.0, compute a uniform scale factor (1.0 / maxAbs) and multiply
every sample by that factor before the existing clamping/rounding conversion so
the buffer is gain-normalized; alternatively add an optional parameter (e.g.,
normalize?: boolean) to enable this peak-scaling behavior, but ensure the
peak-detection and uniform scaling occur inside floatToInt16 before filling
intFrame.

21 changes: 21 additions & 0 deletions src/core/recombination/reconstructor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const FRAME_SIZE = 1024;
const HOP_SIZE = 512;

let overlapBuffer = new Float32Array(HOP_SIZE).fill(0);

/**
* Stitches IFFT frames using Overlap-Add logic.
* @param timeFrame 1024 samples
* @returns 512 samples of finished PCM
*/

export function overlapAdd(timeFrame: Float32Array): Float32Array {
const output = new Float32Array(HOP_SIZE);

for (let i = 0; i < HOP_SIZE; i++) {
output[i] = (overlapBuffer[i]! + timeFrame[i]!) / 1.08;
overlapBuffer[i] = timeFrame[i + HOP_SIZE]!;
}

return output;
Comment on lines +4 to +20

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

Handle the first/last OLA hops explicitly.

On the first call, overlapBuffer is still all zeros, so Line 16 attenuates the opening hop as if a previous frame existed. On the last call, Lines 17-20 keep the trailing 512 samples in overlapBuffer, but src/core/profiler/recorder.ts never flushes them before saveWAV(). The reconstructed output will start with incorrect gain and end one hop short. Please add explicit priming/finalization handling for the overlap buffer instead of treating every frame as an interior frame.

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

In `@src/core/recombination/reconstructor.ts` around lines 4 - 20, The overlap-add
logic treats every frame as an interior hop, causing the first output to be
attenuated and trailing samples to be left unflushed; fix by (1) adding a local
primed flag (e.g., overlapPrimed) and update overlapAdd to skip the 1.08
division on the very first call (use raw overlapBuffer + timeFrame for the
initial hop) and set overlapPrimed=true after that call, and (2) add an exported
flushOverlapAdd (or flushOverlapBuffer) function that returns the remaining
HOP_SIZE trailing samples (applying any final scaling if needed), clears
overlapBuffer and resets overlapPrimed so recorder.saveWAV can call it to append
the final hop; reference symbols: overlapAdd, overlapBuffer, HOP_SIZE, and
create flushOverlapAdd / overlapPrimed for use by recorder.saveWAV.

}
21 changes: 21 additions & 0 deletions src/core/recombination/writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import wavefile from "wavefile";
import fs from "node:fs";

let pcmAccumulator: Int16Array[] = [];

export function collectOutput(pcmFrame: Int16Array): void {
// const newBuffer = new Int16Array(pcmAccumulator.length + pcmFrame.length);
// newBuffer.set(pcmAccumulator);
// newBuffer.set(pcmFrame, pcmAccumulator.length);
// pcmAccumulator = newBuffer;

pcmAccumulator.push(pcmFrame.slice());
}

export function saveWAV(filename: string = "output.wav"): void {
const wav = new wavefile.WaveFile();
wav.fromScratch(1, 16000, "16", pcmAccumulator);
fs.writeFileSync(filename, wav.toBuffer());
console.log(`\n stealth audio saved: ${filename}`);
// pcmAccumulator = new Int16Array(0);
}
Comment on lines +15 to +21

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

🧩 Analysis chain

🌐 Web query:

wavefile npm fromScratch samples parameter type

💡 Result:

In wavefile’s WaveFile.fromScratch(numChannels, sampleRate, bitDepthCode, samples, options?), the samples parameter type is Array | TypedArray. [1]

For multi-channel audio, samples can be provided either:

  • Interleaved (single TypedArray/Array of numbers), or
  • De-interleaved (array of per-channel arrays); wavefile will interleave them for you (as shown in the stereo example). [2]

Sample values are plain numbers, expected to match the chosen bitDepthCode range (e.g., -32768..32767 for "16", -1.0..1.0 for "32f"/"64"). [3]

Sources: [1] (rochars.github.io) [2] (npm.io) [3] (npm.io)

Citations:


Flatten pcmAccumulator chunks before passing to wavefile.

Line 17 passes pcmAccumulator (an Int16Array[] of accumulated chunks) directly to wav.fromScratch(). Since this is mono audio (1 channel), wavefile expects a single flat TypedArray or an array with one channel. Passing an array of chunks will either fail or be misinterpreted as multiple channels.

Combine the chunks into a single Int16Array:

Fix
 export function saveWAV(filename: string = "output.wav"): void {
+  const totalLength = pcmAccumulator.reduce((sum, chunk) => sum + chunk.length, 0);
+  const flatSamples = new Int16Array(totalLength);
+  let offset = 0;
+  for (const chunk of pcmAccumulator) {
+    flatSamples.set(chunk, offset);
+    offset += chunk.length;
+  }
+
   const wav = new wavefile.WaveFile();
-  wav.fromScratch(1, 16000, "16", pcmAccumulator);
+  wav.fromScratch(1, 16000, "16", flatSamples);
   fs.writeFileSync(filename, wav.toBuffer());
   console.log(`\n stealth audio saved: ${filename}`);

Also uncomment the reset (line 21) if pcmAccumulator should be cleared after saving.

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

In `@src/core/recombination/writer.ts` around lines 15 - 21, The saveWAV function
is passing pcmAccumulator (an array of Int16Array chunks) directly to
wav.fromScratch which expects a single flat TypedArray for mono audio; fix by
flattening pcmAccumulator into one Int16Array (concatenate all chunks in
pcmAccumulator into a single Int16Array) and pass that flat array to
wavefile.WaveFile.fromScratch(1, 16000, "16", flatSamples), then write the
buffer as before and re-enable/reset pcmAccumulator (uncomment or reassign it to
a new empty Int16Array) so accumulated chunks are cleared after saving.