roxify 1.6.6 → 1.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
1
  import { createCipheriv, pbkdf2Sync, randomBytes } from 'crypto';
2
2
  import * as zlib from 'zlib';
3
3
  import { unpackBuffer } from '../pack.js';
4
+ import { bytesToWav } from './audio.js';
4
5
  import { COMPRESSION_MARKERS, ENC_AES, ENC_NONE, ENC_XOR, MAGIC, MARKER_END, MARKER_START, PIXEL_MAGIC, PIXEL_MAGIC_BLOCK, PNG_HEADER, } from './constants.js';
5
6
  import { crc32 } from './crc.js';
6
7
  import { colorsToBytes } from './helpers.js';
7
8
  import { native } from './native.js';
9
+ import { encodeRobustAudio } from './robust-audio.js';
10
+ import { encodeRobustImage } from './robust-image.js';
8
11
  import { parallelZstdCompress } from './zstd.js';
9
12
  /**
10
13
  * Encode a buffer or array of buffers into a PNG image (ROX format).
@@ -56,6 +59,105 @@ export async function encodeBinaryToPng(input, opts = {}) {
56
59
  };
57
60
  }
58
61
  }
62
+ const compressionLevel = opts.compressionLevel ?? 19;
63
+ // ─── Lossy-resilient encoding fast path ────────────────────────────────────
64
+ // When lossyResilient is true, use QR-code-style block encoding with
65
+ // Reed-Solomon FEC. This produces output that survives lossy compression.
66
+ if (opts.lossyResilient) {
67
+ const inputBuf = Array.isArray(input) ? Buffer.concat(input) : input;
68
+ if (opts.onProgress)
69
+ opts.onProgress({ phase: 'compress_start', total: inputBuf.length });
70
+ if (opts.container === 'sound') {
71
+ // Robust audio encoding (multi-tone FSK + RS ECC)
72
+ const result = encodeRobustAudio(inputBuf, {
73
+ eccLevel: opts.eccLevel ?? 'medium',
74
+ });
75
+ if (opts.onProgress)
76
+ opts.onProgress({ phase: 'done' });
77
+ progressBar?.stop();
78
+ return result;
79
+ }
80
+ else {
81
+ // Robust image encoding (QR-code-like blocks + RS ECC)
82
+ const result = encodeRobustImage(inputBuf, {
83
+ blockSize: opts.robustBlockSize ?? 4,
84
+ eccLevel: opts.eccLevel ?? 'medium',
85
+ });
86
+ if (opts.onProgress)
87
+ opts.onProgress({ phase: 'done' });
88
+ progressBar?.stop();
89
+ return result;
90
+ }
91
+ }
92
+ // --- Native encoder fast path: let Rust handle compression/encryption/PNG ---
93
+ // This must be checked BEFORE TS compression to avoid double-compression.
94
+ if (typeof native.nativeEncodePngWithNameAndFilelist === 'function' &&
95
+ opts.includeFileList &&
96
+ opts.fileList) {
97
+ const fileName = opts.name || undefined;
98
+ const inputBuf = Array.isArray(input) ? Buffer.concat(input) : input;
99
+ let sizeMap = null;
100
+ try {
101
+ const unpack = unpackBuffer(inputBuf);
102
+ if (unpack) {
103
+ sizeMap = {};
104
+ for (const ef of unpack.files)
105
+ sizeMap[ef.path] = ef.buf.length;
106
+ }
107
+ }
108
+ catch (e) { }
109
+ const normalized = opts.fileList.map((f) => {
110
+ if (typeof f === 'string')
111
+ return { name: f, size: sizeMap && sizeMap[f] ? sizeMap[f] : 0 };
112
+ if (f && typeof f === 'object') {
113
+ if (f.name)
114
+ return { name: f.name, size: f.size ?? 0 };
115
+ if (f.path)
116
+ return { name: f.path, size: f.size ?? 0 };
117
+ }
118
+ return { name: String(f), size: 0 };
119
+ });
120
+ const fileListJson = JSON.stringify(normalized);
121
+ if (opts.onProgress)
122
+ opts.onProgress({ phase: 'compress_start', total: inputBuf.length });
123
+ // ── WAV container (--sound) via native Rust encoder ──
124
+ if (opts.container === 'sound') {
125
+ if (typeof native.nativeEncodeWavWithEncryptionNameAndFilelist === 'function' &&
126
+ opts.passphrase && opts.encrypt && opts.encrypt !== 'auto') {
127
+ const result = native.nativeEncodeWavWithEncryptionNameAndFilelist(inputBuf, compressionLevel, opts.passphrase, opts.encrypt, fileName, fileListJson);
128
+ if (opts.onProgress)
129
+ opts.onProgress({ phase: 'done' });
130
+ progressBar?.stop();
131
+ return Buffer.from(result);
132
+ }
133
+ else if (typeof native.nativeEncodeWavWithNameAndFilelist === 'function') {
134
+ const result = native.nativeEncodeWavWithNameAndFilelist(inputBuf, compressionLevel, fileName, fileListJson);
135
+ if (opts.onProgress)
136
+ opts.onProgress({ phase: 'done' });
137
+ progressBar?.stop();
138
+ return Buffer.from(result);
139
+ }
140
+ // fallthrough to TS WAV path below if native WAV not available
141
+ }
142
+ // ── PNG container (default) via native Rust encoder ──
143
+ if (opts.container !== 'sound') {
144
+ if (opts.passphrase && opts.encrypt && opts.encrypt !== 'auto') {
145
+ const result = native.nativeEncodePngWithEncryptionNameAndFilelist(inputBuf, compressionLevel, opts.passphrase, opts.encrypt, fileName, fileListJson);
146
+ if (opts.onProgress)
147
+ opts.onProgress({ phase: 'done' });
148
+ progressBar?.stop();
149
+ return Buffer.from(result);
150
+ }
151
+ else {
152
+ const result = native.nativeEncodePngWithNameAndFilelist(inputBuf, compressionLevel, fileName, fileListJson);
153
+ if (opts.onProgress)
154
+ opts.onProgress({ phase: 'done' });
155
+ progressBar?.stop();
156
+ return Buffer.from(result);
157
+ }
158
+ }
159
+ }
160
+ // --- TypeScript compression/encryption pipeline ---
59
161
  let payloadInput;
60
162
  let totalLen = 0;
61
163
  if (Array.isArray(input)) {
@@ -68,7 +170,6 @@ export async function encodeBinaryToPng(input, opts = {}) {
68
170
  }
69
171
  if (opts.onProgress)
70
172
  opts.onProgress({ phase: 'compress_start', total: totalLen });
71
- const compressionLevel = opts.compressionLevel ?? 19;
72
173
  let payload = await parallelZstdCompress(payloadInput, compressionLevel, (loaded, total) => {
73
174
  if (opts.onProgress) {
74
175
  opts.onProgress({
@@ -148,48 +249,6 @@ export async function encodeBinaryToPng(input, opts = {}) {
148
249
  const payloadTotalLen = payload.reduce((a, b) => a + b.length, 0);
149
250
  if (opts.onProgress)
150
251
  opts.onProgress({ phase: 'meta_prep_done', loaded: payloadTotalLen });
151
- if (typeof native.nativeEncodePngWithNameAndFilelist === 'function' &&
152
- opts.includeFileList &&
153
- opts.fileList) {
154
- const fileName = opts.name || undefined;
155
- let sizeMap = null;
156
- if (!Array.isArray(input)) {
157
- try {
158
- const unpack = unpackBuffer(input);
159
- if (unpack) {
160
- sizeMap = {};
161
- for (const ef of unpack.files)
162
- sizeMap[ef.path] = ef.buf.length;
163
- }
164
- }
165
- catch (e) { }
166
- }
167
- const normalized = opts.fileList.map((f) => {
168
- if (typeof f === 'string')
169
- return { name: f, size: sizeMap && sizeMap[f] ? sizeMap[f] : 0 };
170
- if (f && typeof f === 'object') {
171
- if (f.name)
172
- return { name: f.name, size: f.size ?? 0 };
173
- if (f.path)
174
- return { name: f.path, size: f.size ?? 0 };
175
- }
176
- return { name: String(f), size: 0 };
177
- });
178
- const fileListJson = JSON.stringify(normalized);
179
- const flatPayload = Buffer.concat(payload);
180
- if (opts.passphrase && opts.encrypt && opts.encrypt !== 'auto') {
181
- const result = native.nativeEncodePngWithEncryptionNameAndFilelist(flatPayload, compressionLevel, opts.passphrase, opts.encrypt, fileName, fileListJson);
182
- if (opts.onProgress)
183
- opts.onProgress({ phase: 'done' });
184
- return Buffer.from(result);
185
- }
186
- else {
187
- const result = native.nativeEncodePngWithNameAndFilelist(flatPayload, compressionLevel, fileName, fileListJson);
188
- if (opts.onProgress)
189
- opts.onProgress({ phase: 'done' });
190
- return Buffer.from(result);
191
- }
192
- }
193
252
  const metaParts = [];
194
253
  const includeName = opts.includeName === undefined ? true : !!opts.includeName;
195
254
  if (includeName && opts.name) {
@@ -233,6 +292,55 @@ export async function encodeBinaryToPng(input, opts = {}) {
233
292
  if (opts.output === 'rox') {
234
293
  return Buffer.concat([MAGIC, ...meta]);
235
294
  }
295
+ // ─── WAV container (TS fallback path) ──────────────────────────────────────
296
+ if (opts.container === 'sound') {
297
+ const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
298
+ const nameLen = nameBuf.length;
299
+ const payloadLenBuf = Buffer.alloc(4);
300
+ payloadLenBuf.writeUInt32BE(payloadTotalLen, 0);
301
+ const version = 1;
302
+ let wavPayload = [
303
+ PIXEL_MAGIC,
304
+ Buffer.from([version]),
305
+ Buffer.from([nameLen]),
306
+ nameBuf,
307
+ payloadLenBuf,
308
+ ...payload,
309
+ ];
310
+ if (opts.includeFileList && opts.fileList) {
311
+ let sizeMapW = null;
312
+ if (!Array.isArray(input)) {
313
+ try {
314
+ const unpack = unpackBuffer(input);
315
+ if (unpack) {
316
+ sizeMapW = {};
317
+ for (const ef of unpack.files)
318
+ sizeMapW[ef.path] = ef.buf.length;
319
+ }
320
+ }
321
+ catch (e) { }
322
+ }
323
+ const normalizedW = opts.fileList.map((f) => {
324
+ if (typeof f === 'string')
325
+ return { name: f, size: sizeMapW && sizeMapW[f] ? sizeMapW[f] : 0 };
326
+ if (f && typeof f === 'object') {
327
+ if (f.name)
328
+ return { name: f.name, size: f.size ?? 0 };
329
+ if (f.path)
330
+ return { name: f.path, size: f.size ?? 0 };
331
+ }
332
+ return { name: String(f), size: 0 };
333
+ });
334
+ const jsonBufW = Buffer.from(JSON.stringify(normalizedW), 'utf8');
335
+ const lenBufW = Buffer.alloc(4);
336
+ lenBufW.writeUInt32BE(jsonBufW.length, 0);
337
+ wavPayload = [...wavPayload, Buffer.from('rXFL', 'utf8'), lenBufW, jsonBufW];
338
+ }
339
+ const wavData = bytesToWav(Buffer.concat(wavPayload));
340
+ payload.length = 0;
341
+ progressBar?.stop();
342
+ return wavData;
343
+ }
236
344
  {
237
345
  const nameBuf = opts.name ? Buffer.from(opts.name, 'utf8') : Buffer.alloc(0);
238
346
  const nameLen = nameBuf.length;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lossy-Resilient Audio Encoding (WAV container).
3
+ *
4
+ * Encodes binary data as multi-frequency tones (OFDM-like) that survive
5
+ * lossy audio compression (MP3, AAC, OGG Vorbis). The output sounds like
6
+ * a series of harmonious chords — not white noise.
7
+ *
8
+ * Architecture:
9
+ * 1. Data is protected with Reed-Solomon ECC (configurable level).
10
+ * 2. Each byte is transmitted as 8 simultaneous frequency channels
11
+ * (one bit per channel: tone present = 1, absent = 0).
12
+ * 3. Raised-cosine windowing prevents spectral splatter.
13
+ * 4. A chirp sync preamble enables frame alignment after lossy re-encoding.
14
+ *
15
+ * Frequency plan:
16
+ * 8 carriers at 600, 900, 1200, 1500, 1800, 2100, 2400, 2700 Hz.
17
+ * All within the 300–3400 Hz band preserved by most lossy codecs.
18
+ *
19
+ * Throughput: ~17 bytes/sec raw (with default symbol timing).
20
+ */
21
+ import { EccLevel } from './ecc.js';
22
+ export interface RobustAudioEncodeOptions {
23
+ /** Error correction level. Default: 'medium'. */
24
+ eccLevel?: EccLevel;
25
+ }
26
+ export interface RobustAudioDecodeResult {
27
+ data: Buffer;
28
+ correctedErrors: number;
29
+ }
30
+ /**
31
+ * Encode binary data into a lossy-resilient WAV file.
32
+ *
33
+ * The output uses multi-frequency tones (not white noise) and includes
34
+ * Reed-Solomon error correction for recovery after MP3/AAC/OGG compression.
35
+ *
36
+ * @param data - Raw data to encode.
37
+ * @param opts - Encoding options.
38
+ * @returns WAV file as a Buffer.
39
+ */
40
+ export declare function encodeRobustAudio(data: Buffer, opts?: RobustAudioEncodeOptions): Buffer;
41
+ /**
42
+ * Decode binary data from a lossy-resilient WAV file.
43
+ *
44
+ * Handles WAV files that have been re-encoded through lossy codecs.
45
+ *
46
+ * @param wav - WAV file buffer (16-bit PCM preferred, 8-bit also accepted).
47
+ * @returns Decoded data and error correction stats.
48
+ */
49
+ export declare function decodeRobustAudio(wav: Buffer): RobustAudioDecodeResult;
50
+ /**
51
+ * Check if a buffer looks like a robust-audio-encoded WAV.
52
+ * Detects the sync preamble signature.
53
+ */
54
+ export declare function isRobustAudioWav(buf: Buffer): boolean;
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Lossy-Resilient Audio Encoding (WAV container).
3
+ *
4
+ * Encodes binary data as multi-frequency tones (OFDM-like) that survive
5
+ * lossy audio compression (MP3, AAC, OGG Vorbis). The output sounds like
6
+ * a series of harmonious chords — not white noise.
7
+ *
8
+ * Architecture:
9
+ * 1. Data is protected with Reed-Solomon ECC (configurable level).
10
+ * 2. Each byte is transmitted as 8 simultaneous frequency channels
11
+ * (one bit per channel: tone present = 1, absent = 0).
12
+ * 3. Raised-cosine windowing prevents spectral splatter.
13
+ * 4. A chirp sync preamble enables frame alignment after lossy re-encoding.
14
+ *
15
+ * Frequency plan:
16
+ * 8 carriers at 600, 900, 1200, 1500, 1800, 2100, 2400, 2700 Hz.
17
+ * All within the 300–3400 Hz band preserved by most lossy codecs.
18
+ *
19
+ * Throughput: ~17 bytes/sec raw (with default symbol timing).
20
+ */
21
+ import { eccDecode, eccEncode } from './ecc.js';
22
+ // ─── Configuration ──────────────────────────────────────────────────────────
23
+ const SAMPLE_RATE = 44100;
24
+ const BITS_PER_SAMPLE = 16;
25
+ const NUM_CHANNELS_WAV = 1; // Mono
26
+ /** Carrier frequencies (Hz). 8 channels = 1 byte per symbol. */
27
+ const CARRIERS = [600, 900, 1200, 1500, 1800, 2100, 2400, 2700];
28
+ /** Samples per symbol (≈46 ms at 44100 Hz). */
29
+ const SYMBOL_SAMPLES = 2048;
30
+ /** Guard interval between symbols (≈12 ms). */
31
+ const GUARD_SAMPLES = 512;
32
+ /** Total samples per symbol including guard. */
33
+ const TOTAL_SYMBOL_SAMPLES = SYMBOL_SAMPLES + GUARD_SAMPLES;
34
+ /** Amplitude per carrier (0–1). With 8 carriers max sum ≈ 2.8. */
35
+ const TONE_AMPLITUDE = 0.35;
36
+ /** Sync preamble: 4 descending tones. */
37
+ const SYNC_FREQS = [3200, 2400, 1600, 800];
38
+ const SYNC_TONE_SAMPLES = 1024; // ≈23 ms per tone
39
+ const SYNC_TOTAL_SAMPLES = SYNC_FREQS.length * SYNC_TONE_SAMPLES;
40
+ /** Goertzel energy threshold (normalized). Calibrated for windowed tones. */
41
+ const DETECTION_THRESHOLD = 0.0005;
42
+ /** Silence at end (ms). */
43
+ const TAIL_SILENCE_MS = 200;
44
+ // ── WAV header ──────────────────────────────────────────────────────────────
45
+ const WAV_HEADER_SIZE = 44;
46
+ function writeWavHeader(buf, dataBytes) {
47
+ const byteRate = SAMPLE_RATE * NUM_CHANNELS_WAV * (BITS_PER_SAMPLE / 8);
48
+ const blockAlign = NUM_CHANNELS_WAV * (BITS_PER_SAMPLE / 8);
49
+ let o = 0;
50
+ buf.write('RIFF', o, 'ascii');
51
+ o += 4;
52
+ buf.writeUInt32LE(WAV_HEADER_SIZE - 8 + dataBytes, o);
53
+ o += 4;
54
+ buf.write('WAVE', o, 'ascii');
55
+ o += 4;
56
+ buf.write('fmt ', o, 'ascii');
57
+ o += 4;
58
+ buf.writeUInt32LE(16, o);
59
+ o += 4; // PCM sub-chunk size
60
+ buf.writeUInt16LE(1, o);
61
+ o += 2; // Audio format (PCM)
62
+ buf.writeUInt16LE(NUM_CHANNELS_WAV, o);
63
+ o += 2;
64
+ buf.writeUInt32LE(SAMPLE_RATE, o);
65
+ o += 4;
66
+ buf.writeUInt32LE(byteRate, o);
67
+ o += 4;
68
+ buf.writeUInt16LE(blockAlign, o);
69
+ o += 2;
70
+ buf.writeUInt16LE(BITS_PER_SAMPLE, o);
71
+ o += 2;
72
+ buf.write('data', o, 'ascii');
73
+ o += 4;
74
+ buf.writeUInt32LE(dataBytes, o);
75
+ }
76
+ // ─── Pre-computed Lookup Tables ─────────────────────────────────────────────
77
+ /** Pre-computed Hann window for symbol length. */
78
+ const HANN_WINDOW = new Float64Array(SYMBOL_SAMPLES);
79
+ {
80
+ const factor = (2 * Math.PI) / (SYMBOL_SAMPLES - 1);
81
+ for (let n = 0; n < SYMBOL_SAMPLES; n++) {
82
+ HANN_WINDOW[n] = 0.5 * (1 - Math.cos(factor * n));
83
+ }
84
+ }
85
+ /** Pre-computed sine tables for each carrier frequency (symbol length). */
86
+ const CARRIER_SINE_TABLES = CARRIERS.map(freq => {
87
+ const table = new Float64Array(SYMBOL_SAMPLES);
88
+ const w = (2 * Math.PI * freq) / SAMPLE_RATE;
89
+ for (let n = 0; n < SYMBOL_SAMPLES; n++) {
90
+ table[n] = TONE_AMPLITUDE * HANN_WINDOW[n] * Math.sin(w * n);
91
+ }
92
+ return table;
93
+ });
94
+ /** Pre-computed sine tables for sync tones. */
95
+ const SYNC_SINE_TABLES = SYNC_FREQS.map(freq => {
96
+ const table = new Float64Array(SYNC_TONE_SAMPLES);
97
+ const w = (2 * Math.PI * freq) / SAMPLE_RATE;
98
+ const factor = (2 * Math.PI) / (SYNC_TONE_SAMPLES - 1);
99
+ for (let n = 0; n < SYNC_TONE_SAMPLES; n++) {
100
+ const window = 0.5 * (1 - Math.cos(factor * n));
101
+ table[n] = 0.3 * window * Math.sin(w * n);
102
+ }
103
+ return table;
104
+ });
105
+ /** Pre-computed Goertzel coefficients for each carrier. */
106
+ const GOERTZEL_COEFFS = CARRIERS.map(freq => {
107
+ const k = Math.round((freq * SYMBOL_SAMPLES) / SAMPLE_RATE);
108
+ const w = (2 * Math.PI * k) / SYMBOL_SAMPLES;
109
+ return { k, coeff: 2 * Math.cos(w) };
110
+ });
111
+ // ─── Signal Processing Primitives ───────────────────────────────────────────
112
+ /**
113
+ * Generate a raised-cosine windowed tone burst.
114
+ */
115
+ function generateTone(freq, numSamples, amplitude) {
116
+ const out = new Float64Array(numSamples);
117
+ const w = (2 * Math.PI * freq) / SAMPLE_RATE;
118
+ for (let n = 0; n < numSamples; n++) {
119
+ // Raised cosine window (Hann)
120
+ const window = 0.5 * (1 - Math.cos((2 * Math.PI * n) / (numSamples - 1)));
121
+ out[n] = amplitude * window * Math.sin(w * n);
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Goertzel algorithm: compute energy at a specific frequency.
127
+ * Returns normalized power (0–1 range for unit-amplitude input).
128
+ */
129
+ function goertzelEnergy(samples, freq, sampleRate) {
130
+ const N = samples.length;
131
+ const k = Math.round((freq * N) / sampleRate);
132
+ const w = (2 * Math.PI * k) / N;
133
+ const coeff = 2 * Math.cos(w);
134
+ let s1 = 0;
135
+ let s2 = 0;
136
+ for (let n = 0; n < N; n++) {
137
+ const s0 = samples[n] + coeff * s1 - s2;
138
+ s2 = s1;
139
+ s1 = s0;
140
+ }
141
+ const power = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / (N * N);
142
+ return power;
143
+ }
144
+ // ─── Modulation / Demodulation ──────────────────────────────────────────────
145
+ /**
146
+ * Modulate a single byte into audio samples (8-channel OFDM symbol).
147
+ * Uses pre-computed sine tables for maximum speed.
148
+ */
149
+ function modulateByte(byte) {
150
+ const out = new Float64Array(TOTAL_SYMBOL_SAMPLES);
151
+ for (let bit = 0; bit < 8; bit++) {
152
+ if (byte & (1 << bit)) {
153
+ const table = CARRIER_SINE_TABLES[bit];
154
+ for (let n = 0; n < SYMBOL_SAMPLES; n++) {
155
+ out[n] += table[n];
156
+ }
157
+ }
158
+ }
159
+ // Guard interval is silence (already zeros)
160
+ return out;
161
+ }
162
+ /**
163
+ * Demodulate audio samples into a byte.
164
+ * Uses pre-computed Goertzel coefficients for maximum speed.
165
+ */
166
+ function demodulateByte(samples) {
167
+ // Extract the active symbol region (skip guard)
168
+ const symbol = samples.subarray(0, SYMBOL_SAMPLES);
169
+ const N = SYMBOL_SAMPLES;
170
+ const N2 = N * N;
171
+ let byte = 0;
172
+ for (let bit = 0; bit < 8; bit++) {
173
+ const { coeff } = GOERTZEL_COEFFS[bit];
174
+ let s1 = 0;
175
+ let s2 = 0;
176
+ for (let n = 0; n < N; n++) {
177
+ const s0 = symbol[n] + coeff * s1 - s2;
178
+ s2 = s1;
179
+ s1 = s0;
180
+ }
181
+ const power = (s1 * s1 + s2 * s2 - coeff * s1 * s2) / N2;
182
+ if (power > DETECTION_THRESHOLD) {
183
+ byte |= 1 << bit;
184
+ }
185
+ }
186
+ return byte;
187
+ }
188
+ /**
189
+ * Generate sync preamble (4 descending tones).
190
+ * Uses pre-computed sine tables.
191
+ */
192
+ function generatePreamble() {
193
+ const out = new Float64Array(SYNC_TOTAL_SAMPLES);
194
+ for (let i = 0; i < SYNC_FREQS.length; i++) {
195
+ const table = SYNC_SINE_TABLES[i];
196
+ const offset = i * SYNC_TONE_SAMPLES;
197
+ out.set(table, offset);
198
+ }
199
+ return out;
200
+ }
201
+ /**
202
+ * Detect sync preamble in audio samples. Returns the sample offset
203
+ * where data symbols begin, or -1 if not found.
204
+ */
205
+ function detectPreamble(samples) {
206
+ // Slide a window looking for the descending tone pattern
207
+ const step = Math.floor(SYNC_TONE_SAMPLES / 4);
208
+ const searchLen = Math.min(samples.length - SYNC_TOTAL_SAMPLES, SAMPLE_RATE * 2);
209
+ for (let offset = 0; offset < searchLen; offset += step) {
210
+ let found = true;
211
+ for (let i = 0; i < SYNC_FREQS.length; i++) {
212
+ const start = offset + i * SYNC_TONE_SAMPLES;
213
+ const segment = samples.subarray(start, start + SYNC_TONE_SAMPLES);
214
+ if (segment.length < SYNC_TONE_SAMPLES) {
215
+ found = false;
216
+ break;
217
+ }
218
+ const energy = goertzelEnergy(segment, SYNC_FREQS[i], SAMPLE_RATE);
219
+ if (energy < DETECTION_THRESHOLD * 0.5) {
220
+ found = false;
221
+ break;
222
+ }
223
+ }
224
+ if (found) {
225
+ return offset + SYNC_TOTAL_SAMPLES;
226
+ }
227
+ }
228
+ return -1;
229
+ }
230
+ /**
231
+ * Encode binary data into a lossy-resilient WAV file.
232
+ *
233
+ * The output uses multi-frequency tones (not white noise) and includes
234
+ * Reed-Solomon error correction for recovery after MP3/AAC/OGG compression.
235
+ *
236
+ * @param data - Raw data to encode.
237
+ * @param opts - Encoding options.
238
+ * @returns WAV file as a Buffer.
239
+ */
240
+ export function encodeRobustAudio(data, opts = {}) {
241
+ const level = opts.eccLevel ?? 'medium';
242
+ // 1. Protect with ECC
243
+ const protected_ = eccEncode(data, level);
244
+ // 2. Prepend a 4-byte length prefix (so the decoder knows how many symbols).
245
+ // The prefix itself is encoded as 4 symbols at the start.
246
+ const numPayloadSymbols = protected_.length;
247
+ const lenPrefix = Buffer.alloc(4);
248
+ lenPrefix.writeUInt32BE(numPayloadSymbols, 0);
249
+ const fullPayload = Buffer.concat([lenPrefix, protected_]);
250
+ // 3. Build audio samples
251
+ const preamble = generatePreamble();
252
+ const numSymbols = fullPayload.length;
253
+ const tailSamples = Math.floor(SAMPLE_RATE * TAIL_SILENCE_MS / 1000);
254
+ const totalSamples = SYNC_TOTAL_SAMPLES +
255
+ numSymbols * TOTAL_SYMBOL_SAMPLES +
256
+ tailSamples;
257
+ // 4. Convert to 16-bit PCM WAV directly (skip intermediate Float64Array)
258
+ const dataBytes = totalSamples * 2; // 16-bit = 2 bytes/sample
259
+ const wav = Buffer.alloc(WAV_HEADER_SIZE + dataBytes);
260
+ writeWavHeader(wav, dataBytes);
261
+ let offset = WAV_HEADER_SIZE;
262
+ // Write preamble directly
263
+ for (let n = 0; n < SYNC_TOTAL_SAMPLES; n++) {
264
+ const sample = Math.max(-1, Math.min(1, preamble[n]));
265
+ wav.writeInt16LE(Math.round(sample * 32767), offset);
266
+ offset += 2;
267
+ }
268
+ // Write data symbols directly (avoid allocating a huge Float64Array)
269
+ for (let i = 0; i < numSymbols; i++) {
270
+ const symbol = modulateByte(fullPayload[i]);
271
+ for (let n = 0; n < TOTAL_SYMBOL_SAMPLES; n++) {
272
+ const sample = Math.max(-1, Math.min(1, symbol[n]));
273
+ wav.writeInt16LE(Math.round(sample * 32767), offset);
274
+ offset += 2;
275
+ }
276
+ }
277
+ // Tail silence is already zeros in the buffer
278
+ return wav;
279
+ }
280
+ /**
281
+ * Decode binary data from a lossy-resilient WAV file.
282
+ *
283
+ * Handles WAV files that have been re-encoded through lossy codecs.
284
+ *
285
+ * @param wav - WAV file buffer (16-bit PCM preferred, 8-bit also accepted).
286
+ * @returns Decoded data and error correction stats.
287
+ */
288
+ export function decodeRobustAudio(wav) {
289
+ // Parse WAV header
290
+ if (wav.length < WAV_HEADER_SIZE)
291
+ throw new Error('WAV too short');
292
+ if (wav.toString('ascii', 0, 4) !== 'RIFF')
293
+ throw new Error('Not a RIFF file');
294
+ if (wav.toString('ascii', 8, 12) !== 'WAVE')
295
+ throw new Error('Not a WAVE file');
296
+ // Find data chunk
297
+ let chunkOffset = 12;
298
+ let pcmStart = 0;
299
+ let pcmSize = 0;
300
+ let bitsPerSample = 16;
301
+ while (chunkOffset + 8 <= wav.length) {
302
+ const chunkId = wav.toString('ascii', chunkOffset, chunkOffset + 4);
303
+ const chunkSize = wav.readUInt32LE(chunkOffset + 4);
304
+ if (chunkId === 'fmt ') {
305
+ bitsPerSample = wav.readUInt16LE(chunkOffset + 22);
306
+ }
307
+ else if (chunkId === 'data') {
308
+ pcmStart = chunkOffset + 8;
309
+ pcmSize = chunkSize;
310
+ break;
311
+ }
312
+ chunkOffset += 8 + chunkSize;
313
+ if (chunkSize % 2 !== 0)
314
+ chunkOffset++; // word alignment
315
+ }
316
+ if (pcmStart === 0)
317
+ throw new Error('No data chunk in WAV');
318
+ // Convert PCM to float64
319
+ const bytesPerSample = bitsPerSample / 8;
320
+ const numPcmSamples = Math.floor(pcmSize / bytesPerSample);
321
+ const audioFloat = new Float64Array(numPcmSamples);
322
+ for (let i = 0; i < numPcmSamples; i++) {
323
+ const pos = pcmStart + i * bytesPerSample;
324
+ if (bitsPerSample === 16) {
325
+ audioFloat[i] = wav.readInt16LE(pos) / 32768;
326
+ }
327
+ else if (bitsPerSample === 8) {
328
+ audioFloat[i] = (wav[pos] - 128) / 128;
329
+ }
330
+ else {
331
+ throw new Error(`Unsupported bits per sample: ${bitsPerSample}`);
332
+ }
333
+ }
334
+ // Find sync preamble
335
+ let dataStart = detectPreamble(audioFloat);
336
+ if (dataStart < 0) {
337
+ // Fallback: assume preamble at start
338
+ dataStart = SYNC_TOTAL_SAMPLES;
339
+ }
340
+ // Demodulate: first 4 symbols are the length prefix, then payload.
341
+ let pos = dataStart;
342
+ // Read length prefix (4 bytes = 4 symbols)
343
+ const lenBytes = [];
344
+ for (let i = 0; i < 4; i++) {
345
+ if (pos + TOTAL_SYMBOL_SAMPLES > audioFloat.length) {
346
+ throw new Error('Audio too short: cannot read length prefix');
347
+ }
348
+ const segment = audioFloat.subarray(pos, pos + TOTAL_SYMBOL_SAMPLES);
349
+ lenBytes.push(demodulateByte(segment));
350
+ pos += TOTAL_SYMBOL_SAMPLES;
351
+ }
352
+ const numPayloadSymbols = (lenBytes[0] << 24) | (lenBytes[1] << 16) | (lenBytes[2] << 8) | lenBytes[3];
353
+ if (numPayloadSymbols <= 0 || numPayloadSymbols > 1e7) {
354
+ throw new Error(`Invalid payload length: ${numPayloadSymbols}`);
355
+ }
356
+ // Read exactly numPayloadSymbols symbols
357
+ const bytes = [];
358
+ for (let i = 0; i < numPayloadSymbols; i++) {
359
+ if (pos + TOTAL_SYMBOL_SAMPLES > audioFloat.length) {
360
+ break; // truncated
361
+ }
362
+ const segment = audioFloat.subarray(pos, pos + TOTAL_SYMBOL_SAMPLES);
363
+ bytes.push(demodulateByte(segment));
364
+ pos += TOTAL_SYMBOL_SAMPLES;
365
+ }
366
+ if (bytes.length === 0) {
367
+ throw new Error('No data symbols detected in audio');
368
+ }
369
+ // Decode ECC
370
+ const eccBuffer = Buffer.from(bytes);
371
+ const { data, totalCorrected } = eccDecode(eccBuffer);
372
+ return { data, correctedErrors: totalCorrected };
373
+ }
374
+ /**
375
+ * Check if a buffer looks like a robust-audio-encoded WAV.
376
+ * Detects the sync preamble signature.
377
+ */
378
+ export function isRobustAudioWav(buf) {
379
+ if (buf.length < WAV_HEADER_SIZE + SYNC_TOTAL_SAMPLES * 2)
380
+ return false;
381
+ // Must be RIFF/WAVE
382
+ if (buf.toString('ascii', 0, 4) !== 'RIFF')
383
+ return false;
384
+ if (buf.toString('ascii', 8, 12) !== 'WAVE')
385
+ return false;
386
+ // Check for 16-bit format (our robust audio uses 16-bit)
387
+ let off = 12;
388
+ while (off + 8 <= buf.length) {
389
+ const id = buf.toString('ascii', off, off + 4);
390
+ const sz = buf.readUInt32LE(off + 4);
391
+ if (id === 'fmt ') {
392
+ const bps = buf.readUInt16LE(off + 22);
393
+ return bps === 16;
394
+ }
395
+ off += 8 + sz;
396
+ if (sz % 2 !== 0)
397
+ off++;
398
+ }
399
+ return false;
400
+ }