roxify 1.6.5 → 1.6.7

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.
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lossy-Resilient Image Encoding (PNG container).
3
+ *
4
+ * Encodes binary data using QR-code-inspired techniques:
5
+ * - Large pixel blocks (configurable 2×2 to 8×8) for JPEG/WebP resilience.
6
+ * - Binary encoding (black/white) with threshold detection.
7
+ * - Finder patterns at corners for automatic alignment.
8
+ * - Reed-Solomon error correction with configurable redundancy.
9
+ * - Byte-level interleaving to spread burst errors across RS blocks.
10
+ *
11
+ * The resulting image looks like a structured data pattern (similar to
12
+ * a QR code) and can be recovered even after JPEG recompression at
13
+ * quality levels as low as 30–50.
14
+ */
15
+ import { EccLevel } from './ecc.js';
16
+ export interface RobustImageEncodeOptions {
17
+ /** Pixel size per data block (2–8). Higher = more lossy resilience. Default: 4. */
18
+ blockSize?: number;
19
+ /** Error correction level. Default: 'medium'. */
20
+ eccLevel?: EccLevel;
21
+ }
22
+ export interface RobustImageDecodeResult {
23
+ data: Buffer;
24
+ correctedErrors: number;
25
+ }
26
+ /**
27
+ * Encode binary data into a lossy-resilient PNG image.
28
+ *
29
+ * The output uses QR-code-like techniques:
30
+ * - Finder patterns for alignment after re-encoding.
31
+ * - Large pixel blocks for JPEG/WebP resilience.
32
+ * - Reed-Solomon ECC for automatic error correction.
33
+ *
34
+ * @param data - Raw data to encode.
35
+ * @param opts - Encoding options.
36
+ * @returns PNG image as a Buffer.
37
+ */
38
+ export declare function encodeRobustImage(data: Buffer, opts?: RobustImageEncodeOptions): Buffer;
39
+ /**
40
+ * Decode binary data from a lossy-resilient PNG image.
41
+ *
42
+ * Handles images that have been re-encoded through JPEG/WebP at various
43
+ * quality levels. The Reed-Solomon ECC layer corrects any bit errors
44
+ * introduced by lossy compression.
45
+ *
46
+ * @param png - PNG (or raw RGB) image buffer.
47
+ * @returns Decoded data and error correction stats.
48
+ */
49
+ export declare function decodeRobustImage(png: Buffer): RobustImageDecodeResult;
50
+ /**
51
+ * Check if a PNG buffer contains a robust-image-encoded payload.
52
+ * Looks for finder patterns in the corners.
53
+ */
54
+ export declare function isRobustImage(png: Buffer): boolean;