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.
- package/README.md +223 -22
- package/dist/cli.js +70 -11
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/utils/audio.d.ts +23 -0
- package/dist/utils/audio.js +98 -0
- package/dist/utils/decoder.js +104 -0
- package/dist/utils/ecc.d.ts +75 -0
- package/dist/utils/ecc.js +446 -0
- package/dist/utils/encoder.js +151 -43
- package/dist/utils/robust-audio.d.ts +54 -0
- package/dist/utils/robust-audio.js +400 -0
- package/dist/utils/robust-image.d.ts +54 -0
- package/dist/utils/robust-image.js +515 -0
- package/dist/utils/types.d.ts +24 -0
- package/dist/utils/zstd.js +26 -12
- package/package.json +1 -1
- package/roxify_native-x86_64-unknown-linux-gnu.node +0 -0
- package/roxify_native.node +0 -0
package/dist/utils/encoder.js
CHANGED
|
@@ -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
|
+
}
|