filejack 1.0.0__tar.gz

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.
filejack-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Staheos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: filejack
3
+ Version: 1.0.0
4
+ Summary: File encoding/decoding to audio signals.
5
+ Author: Staheos
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Staheos/FileJack
8
+ Project-URL: Source, https://github.com/Staheos/FileJack
9
+ Project-URL: Issues, https://github.com/Staheos/FileJack/issues
10
+ Keywords: file,audio,jack,encoder,decoder
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.15.0
23
+ Requires-Dist: scipy>=1.0.0
24
+ Requires-Dist: reedsolo>=1.0.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0; extra == "test"
27
+ Requires-Dist: pytest-cov; extra == "test"
28
+ Requires-Dist: pytest-xdist[psutil]>=3.5; extra == "test"
29
+ Dynamic: license-file
30
+
31
+ # FileJack
32
+
33
+ File encoding/decoding to audio signals.
34
+
35
+ FileJack turns an arbitrary file into a WAV audio signal and back. The data is
36
+ carried on a 12 kHz carrier using differential 8-PSK modulation, protected with
37
+ Reed-Solomon error correction and a per-frame CRC. It is meant for sending files
38
+ over an audio link (for example a 3.5 mm jack cable or an acoustic channel).
39
+
40
+ ## How it works
41
+
42
+ The encoder splits the input into frames. Each frame carries a header
43
+ (sequence number, total frame count, payload length), the payload and a CRC32,
44
+ then gets Reed-Solomon encoded into a 255-byte codeword (223 data bytes + 32
45
+ parity bytes, so up to 209 payload bytes per frame). Each codeword is mapped to
46
+ symbols and modulated onto the carrier, preceded by a preamble and a syncword so
47
+ the decoder can find frame boundaries.
48
+
49
+ The decoder demodulates the recording, locks onto the preamble/syncword,
50
+ recovers the symbols, runs Reed-Solomon correction and checks each frame's CRC.
51
+ Valid frames are collected by sequence number and reassembled into the original
52
+ file. Decoding runs across multiple threads over overlapping blocks, so partial
53
+ or noisy recordings still recover whatever frames are intact.
54
+
55
+ Signal parameters live in [filejack/values.py](filejack/values.py): 48 kHz
56
+ sample rate, 4000 baud, 12 kHz carrier, 8-PSK.
57
+
58
+ ## Installation
59
+
60
+ ```
61
+ pip install filejack
62
+ ```
63
+
64
+ Or from source:
65
+
66
+ ```
67
+ pip install .
68
+ ```
69
+
70
+ Dependencies: numpy, scipy, reedsolo.
71
+
72
+ ## Command line usage
73
+
74
+ Encoding writes a WAV file. By default it produces an anti-phase stereo signal
75
+ (the right channel is the inverted left channel), which lets the decoder cancel
76
+ common-mode noise by subtracting the channels.
77
+
78
+ ```
79
+ filejack encode input.bin
80
+ filejack encode input.bin output.wav
81
+ filejack encode input.bin output.wav --mono
82
+ ```
83
+
84
+ Decoding reads a WAV file and writes the reconstructed file. Stereo input is
85
+ folded down automatically.
86
+
87
+ ```
88
+ filejack decode output.wav
89
+ filejack decode output.wav recovered.bin
90
+ ```
91
+
92
+ Decode options:
93
+
94
+ - `--fjf PATH` also saves the decoded frames into a `.fjf` file. This lets you
95
+ merge frames recovered from several recordings of the same transmission.
96
+ - `--threads N` sets the number of decoding threads (default 12).
97
+
98
+ The command is also available as `python -m filejack`.
99
+
100
+ ## Library usage
101
+
102
+ ```python
103
+ import zlib
104
+ from scipy.io import wavfile
105
+ import numpy as np
106
+
107
+ from filejack.encode_frames import encode_data
108
+ from filejack.decode_data import decode_data
109
+ from filejack.reconstruct_data import reconstruct_data
110
+ from filejack.values import SAMPLE_RATE
111
+
112
+ data = open("input.bin", "rb").read()
113
+
114
+ # Encode to samples and write a WAV
115
+ waveform = encode_data(data)
116
+ wavfile.write("output.wav", SAMPLE_RATE, waveform)
117
+
118
+ # Read a WAV and decode
119
+ fs, rx = wavfile.read("output.wav")
120
+ rx = rx.astype(np.float32)
121
+ if rx.ndim == 2:
122
+ rx = (rx[:, 0] - rx[:, 1]) / 2
123
+
124
+ frames_payload, total_expected, stats = decode_data(rx)
125
+ recovered = reconstruct_data(frames_payload, total_expected)
126
+
127
+ assert zlib.crc32(recovered) == zlib.crc32(data)
128
+ ```
129
+
130
+ More runnable scripts are in [filejack/examples](filejack/examples), including
131
+ `stereo_to_mono.py` for folding an anti-phase stereo recording down to mono.
132
+
133
+ ## .fjf files
134
+
135
+ A `.fjf` (FileJack Frames) file stores decoded frames with their sequence
136
+ numbers. Because each frame is independent, you can decode the same
137
+ transmission several times, save each attempt to a `.fjf`, and merge them to
138
+ fill in frames that were missed in individual passes. See
139
+ [filejack/merge_frames.py](filejack/merge_frames.py) and the merge example.
140
+
141
+ ## Development
142
+
143
+ ```
144
+ pip install .[test]
145
+ pytest
146
+ ```
147
+
148
+ ## License
149
+
150
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,120 @@
1
+ # FileJack
2
+
3
+ File encoding/decoding to audio signals.
4
+
5
+ FileJack turns an arbitrary file into a WAV audio signal and back. The data is
6
+ carried on a 12 kHz carrier using differential 8-PSK modulation, protected with
7
+ Reed-Solomon error correction and a per-frame CRC. It is meant for sending files
8
+ over an audio link (for example a 3.5 mm jack cable or an acoustic channel).
9
+
10
+ ## How it works
11
+
12
+ The encoder splits the input into frames. Each frame carries a header
13
+ (sequence number, total frame count, payload length), the payload and a CRC32,
14
+ then gets Reed-Solomon encoded into a 255-byte codeword (223 data bytes + 32
15
+ parity bytes, so up to 209 payload bytes per frame). Each codeword is mapped to
16
+ symbols and modulated onto the carrier, preceded by a preamble and a syncword so
17
+ the decoder can find frame boundaries.
18
+
19
+ The decoder demodulates the recording, locks onto the preamble/syncword,
20
+ recovers the symbols, runs Reed-Solomon correction and checks each frame's CRC.
21
+ Valid frames are collected by sequence number and reassembled into the original
22
+ file. Decoding runs across multiple threads over overlapping blocks, so partial
23
+ or noisy recordings still recover whatever frames are intact.
24
+
25
+ Signal parameters live in [filejack/values.py](filejack/values.py): 48 kHz
26
+ sample rate, 4000 baud, 12 kHz carrier, 8-PSK.
27
+
28
+ ## Installation
29
+
30
+ ```
31
+ pip install filejack
32
+ ```
33
+
34
+ Or from source:
35
+
36
+ ```
37
+ pip install .
38
+ ```
39
+
40
+ Dependencies: numpy, scipy, reedsolo.
41
+
42
+ ## Command line usage
43
+
44
+ Encoding writes a WAV file. By default it produces an anti-phase stereo signal
45
+ (the right channel is the inverted left channel), which lets the decoder cancel
46
+ common-mode noise by subtracting the channels.
47
+
48
+ ```
49
+ filejack encode input.bin
50
+ filejack encode input.bin output.wav
51
+ filejack encode input.bin output.wav --mono
52
+ ```
53
+
54
+ Decoding reads a WAV file and writes the reconstructed file. Stereo input is
55
+ folded down automatically.
56
+
57
+ ```
58
+ filejack decode output.wav
59
+ filejack decode output.wav recovered.bin
60
+ ```
61
+
62
+ Decode options:
63
+
64
+ - `--fjf PATH` also saves the decoded frames into a `.fjf` file. This lets you
65
+ merge frames recovered from several recordings of the same transmission.
66
+ - `--threads N` sets the number of decoding threads (default 12).
67
+
68
+ The command is also available as `python -m filejack`.
69
+
70
+ ## Library usage
71
+
72
+ ```python
73
+ import zlib
74
+ from scipy.io import wavfile
75
+ import numpy as np
76
+
77
+ from filejack.encode_frames import encode_data
78
+ from filejack.decode_data import decode_data
79
+ from filejack.reconstruct_data import reconstruct_data
80
+ from filejack.values import SAMPLE_RATE
81
+
82
+ data = open("input.bin", "rb").read()
83
+
84
+ # Encode to samples and write a WAV
85
+ waveform = encode_data(data)
86
+ wavfile.write("output.wav", SAMPLE_RATE, waveform)
87
+
88
+ # Read a WAV and decode
89
+ fs, rx = wavfile.read("output.wav")
90
+ rx = rx.astype(np.float32)
91
+ if rx.ndim == 2:
92
+ rx = (rx[:, 0] - rx[:, 1]) / 2
93
+
94
+ frames_payload, total_expected, stats = decode_data(rx)
95
+ recovered = reconstruct_data(frames_payload, total_expected)
96
+
97
+ assert zlib.crc32(recovered) == zlib.crc32(data)
98
+ ```
99
+
100
+ More runnable scripts are in [filejack/examples](filejack/examples), including
101
+ `stereo_to_mono.py` for folding an anti-phase stereo recording down to mono.
102
+
103
+ ## .fjf files
104
+
105
+ A `.fjf` (FileJack Frames) file stores decoded frames with their sequence
106
+ numbers. Because each frame is independent, you can decode the same
107
+ transmission several times, save each attempt to a `.fjf`, and merge them to
108
+ fill in frames that were missed in individual passes. See
109
+ [filejack/merge_frames.py](filejack/merge_frames.py) and the merge example.
110
+
111
+ ## Development
112
+
113
+ ```
114
+ pip install .[test]
115
+ pytest
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT. See [LICENSE](LICENSE).
filejack-1.0.0/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
@@ -0,0 +1,231 @@
1
+ import queue
2
+ import threading
3
+ import time
4
+ import zlib
5
+ from collections import deque
6
+ from concurrent.futures.thread import ThreadPoolExecutor
7
+ from dataclasses import dataclass
8
+ from fractions import Fraction
9
+ from statistics import median
10
+
11
+ from reedsolo import ReedSolomonError
12
+ from scipy.signal import hilbert, butter, sosfiltfilt, resample_poly
13
+
14
+ from scipy.io import wavfile
15
+
16
+ from .conversions import *
17
+ from .decode_frames import decode_frames
18
+ from .values import *
19
+ from .RSC import *
20
+ import numpy as np
21
+ # import sounddevice as sd
22
+
23
+
24
+ @dataclass
25
+ class Chunk:
26
+ stream_id: str
27
+ start_abs: int # absolute sample index for this source stream
28
+ samples: np.ndarray # float32
29
+
30
+ class SourceBuffer:
31
+ def __init__(self, overlap: int):
32
+ self.total = 0
33
+ self.parts = deque()
34
+ self.overlap = overlap
35
+
36
+ def append(self, x: np.ndarray):
37
+ x = np.asarray(x, dtype=np.float32)
38
+
39
+ self.parts.append(x)
40
+ self.total += len(x)
41
+
42
+ def available_abs_end(self) -> int:
43
+ return self.total - 2 * self.overlap
44
+
45
+ def pop_slice(self, size) -> np.ndarray:
46
+ abs_start = 0
47
+ abs_end = size + self.overlap * 2
48
+
49
+ need = abs_end - abs_start
50
+
51
+ if need > self.total:
52
+ raise IndexError("slice not available yet")
53
+
54
+ out = np.empty(need, dtype=np.float32)
55
+ w = 0
56
+
57
+ while self.parts and w < need:
58
+ if len(self.parts[0]) <= need - w - 2 * self.overlap:
59
+ seg = self.parts.popleft()
60
+ out[w:w+len(seg)] = seg
61
+ w += len(seg)
62
+ else:
63
+ seg = self.parts[0][:need - w]
64
+ out[w:w+len(seg)] = seg
65
+ self.parts[0] = self.parts[0][need - w - 2 * self.overlap:]
66
+ w += len(seg)
67
+
68
+ self.total -= need - 2 * self.overlap
69
+ return out
70
+
71
+
72
+ class AsyncDecodeManager:
73
+ def __init__(self, block_sec=3.0, overlap_sec=0.1):
74
+ self.block_samples_len = int(round(SAMPLE_RATE * block_sec))
75
+ self.overlap_samples_len = int(round(SAMPLE_RATE * overlap_sec))
76
+ self.buffer = np.array([], dtype=np.float32)
77
+ self.frames_payload = {}
78
+ self.total_expected = None
79
+ self.lock = threading.Lock()
80
+ self.rsc_error = 0
81
+ self.header_error = 0
82
+ self.crc_error = 0
83
+ self.duplicate_frames = 0
84
+
85
+ self.no_lock = 0
86
+ self.rsc_error = 0
87
+ self.header_error = 0
88
+ self.crc_error = 0
89
+
90
+ self.buffers: dict[str, SourceBuffer] = {}
91
+ self.next_block_start: dict[str, int] = {}
92
+
93
+ self.queue = queue.Queue(maxsize=2000)
94
+ self.executor = ThreadPoolExecutor(max_workers=32)
95
+ self.limit = threading.BoundedSemaphore(32)
96
+
97
+ self._stop = threading.Event()
98
+ self._thread = threading.Thread(target=self._run, daemon=True)
99
+
100
+ self.max_keep = 5 * self.block_samples_len + 3 * self.overlap_samples_len
101
+
102
+ def start(self):
103
+
104
+ self._stop = threading.Event()
105
+ self._thread.start()
106
+
107
+ def stop(self):
108
+ self._stop.set()
109
+ self._thread.join()
110
+ self.executor.shutdown(wait=True)
111
+
112
+ def push_chunk(self, chunk):
113
+ # If you have sources with different fs: resample here to target_fs (not shown)
114
+ self.queue.put(chunk)
115
+
116
+ def _ensure_stream(self, stream_id: str):
117
+ if stream_id not in self.buffers:
118
+ self.buffers[stream_id] = SourceBuffer(overlap=self.overlap_samples_len)
119
+ self.next_block_start[stream_id] = 0
120
+
121
+ def _run(self):
122
+ while not self._stop.is_set():
123
+ try:
124
+ chunk = self.queue.get(timeout=0.1)
125
+ except queue.Empty:
126
+ time.sleep(0.1)
127
+ continue
128
+
129
+ try:
130
+ self._ensure_stream(chunk.stream_id)
131
+ sb = self.buffers[chunk.stream_id]
132
+ sb.append(chunk.samples)
133
+ self._schedule_ready(chunk.stream_id)
134
+ finally:
135
+ self.queue.task_done()
136
+
137
+ def _schedule_ready(self, stream_id: str):
138
+ sb = self.buffers[stream_id]
139
+ next = self.next_block_start[stream_id]
140
+
141
+ while True:
142
+ start = max(0, next - self.overlap_samples_len)
143
+ end = start + self.block_samples_len + 2 * self.overlap_samples_len
144
+
145
+ # start = max(0, next)
146
+ # end = start + self.block_samples_len
147
+
148
+ if sb.available_abs_end() < self.block_samples_len:
149
+ break # not enough samples yet
150
+
151
+ rx_block = sb.pop_slice(self.block_samples_len)
152
+
153
+ # submit decode job
154
+ # self.limit.acquire()
155
+ fut = self.executor.submit(
156
+ self._decode_one,
157
+ rx_block, stream_id, start, end
158
+ )
159
+ fut.add_done_callback(self._merge_result)
160
+
161
+ # advance to next valid block (no overlap in valid regions)
162
+ next += self.block_samples_len
163
+ self.next_block_start[stream_id] = next
164
+
165
+ def _decode_one(self, rx_block, stream_id: str, start, end):
166
+ frames_block, total_expected, ok_log, no_lock, rsc_error, header_error, crc_error = decode_frames(
167
+ rx_block, SAMPLE_RATE,
168
+ search_step=SAMPLES_PER_SYMBOL, quick=True
169
+ )
170
+ return (frames_block, total_expected, ok_log, no_lock, rsc_error, header_error, crc_error, stream_id, start, end)
171
+
172
+ def _merge_result(self, fut):
173
+ # self.limit.release()
174
+ payloads, total, ok_log, no_lock, rsc_error, header_error, crc_error, stream_id, start, end = fut.result()
175
+ with self.lock:
176
+ if total is not None and self.total_expected is None:
177
+ self.total_expected = total
178
+
179
+ for seq, payload in payloads.items():
180
+ self.no_lock += no_lock
181
+ self.rsc_error += rsc_error
182
+ self.header_error += header_error
183
+ self.crc_error += crc_error
184
+
185
+ if seq not in self.frames_payload:
186
+ self.frames_payload[seq] = payload
187
+ else:
188
+ self.duplicate_frames += 1
189
+
190
+ print(f"\rBlock(Sample): {end // self.block_samples_len}/{self.buffers[stream_id].available_abs_end() // self.block_samples_len}({end}/{self.buffers[stream_id].available_abs_end()}) Frames: {len(self.frames_payload)}(+{self.duplicate_frames})/{self.total_expected} no_lock: {self.no_lock} rsc_error: {self.rsc_error} header_error: {self.header_error} crc_error: {self.crc_error}", end='')
191
+
192
+
193
+ def feed_wav(manager: AsyncDecodeManager, stream_id: str, path: str, chunk_size=16000):
194
+ fs, rx = wavfile.read(path)
195
+ rx = rx.astype(np.float32)
196
+ pos = 0
197
+ while pos < len(rx):
198
+ x = rx[pos:pos+chunk_size]
199
+ manager.push_chunk(Chunk(stream_id=stream_id, start_abs=pos, samples=x))
200
+ pos += len(x)
201
+ manager.push_chunk(Chunk(stream_id, start_abs=pos, samples=np.zeros((manager.block_samples_len + manager.overlap_samples_len,), dtype=np.float32)))
202
+
203
+
204
+ if __name__ == '__main__':
205
+ file = "bmp"
206
+
207
+ manager = AsyncDecodeManager()
208
+ manager.start()
209
+
210
+ # sample_pos = 0
211
+ # def callback(indata, frames, time_info, status):
212
+ # if status:
213
+ # print(status, flush=True)
214
+ #
215
+ # global sample_pos
216
+ # manager.push_chunk(Chunk(stream_id="mic", start_abs=sample_pos, samples=indata[:, 0].copy()))
217
+ # sample_pos += indata[:, 0].shape[0]
218
+ #
219
+ # stream = sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype='float32', blocksize=4096, callback=callback)
220
+ #
221
+ # stream.start()
222
+ #
223
+ # while True:
224
+ # time.sleep(1)
225
+
226
+ feed_wav(manager, "wav", f"{file}.wav")
227
+ print("read wav")
228
+ # feed_wav(manager, "wav1", f"{file}1.wav")
229
+
230
+ manager.queue.join()
231
+ manager.stop()
@@ -0,0 +1,50 @@
1
+ import struct
2
+ from reedsolo import RSCodec
3
+
4
+ from .values import *
5
+
6
+
7
+ N = 255
8
+ NSYM = 32
9
+ K = N - NSYM
10
+ RSC = RSCodec(NSYM)
11
+
12
+ HDR_FMT = ">IIH"
13
+ HDR_LEN = struct.calcsize(HDR_FMT)
14
+
15
+ CRC_LEN = 4
16
+
17
+ MAX_PAYLOAD = K - HDR_LEN - CRC_LEN
18
+
19
+
20
+ def lfsr_bits(n: int, seed: int, taps=(7, 6)) -> list[int]:
21
+ """
22
+ Simple LFSR bit generator.
23
+ taps are 1-indexed bit positions within the register size (max(taps)).
24
+ """
25
+ m = max(taps)
26
+ mask = (1 << m) - 1
27
+ reg = seed & mask
28
+ if reg == 0:
29
+ reg = 1 # avoid stuck-at-zero
30
+
31
+ out = []
32
+ for _ in range(n):
33
+ # output bit (LSB)
34
+ out.append(reg & 1)
35
+
36
+ # feedback = XOR of tap bits
37
+ fb = 0
38
+ for t in taps:
39
+ fb ^= (reg >> (t - 1)) & 1
40
+
41
+ # shift right, insert feedback at MSB
42
+ reg = (reg >> 1) | (fb << (m - 1))
43
+ reg &= mask
44
+ return out
45
+
46
+ def bits_to_steps(bits01: list[int]) -> list[int]:
47
+ return [PSK // 2 if b else 0 for b in bits01]
48
+
49
+ PREAMBLE_STEPS = bits_to_steps(lfsr_bits(PREAMBLE_SYMS, seed=0x5D, taps=(7, 6)))
50
+ SYNCWORD_STEPS = bits_to_steps(lfsr_bits(SYNCWORD_SYMS, seed=0x6B, taps=(7, 6)))
@@ -0,0 +1,6 @@
1
+ from .conversions import bytes_to_symbols, symbols_to_bytes
2
+ from .encode_frames import build_frames, encode_frames, encode_data
3
+ from .decode_frames import decode_frames
4
+ from .decode_data import decode_data
5
+ from .merge_frames import merge_frames, save_fjf, load_fjf
6
+ from .reconstruct_data import reconstruct_data
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,96 @@
1
+ import argparse
2
+ import os
3
+ import zlib
4
+
5
+ import numpy as np
6
+ from scipy.io import wavfile
7
+
8
+ from .decode_data import decode_data
9
+ from .encode_frames import encode_data
10
+ from .merge_frames import save_fjf
11
+ from .reconstruct_data import reconstruct_data
12
+ from .values import *
13
+
14
+
15
+ def encode_command(args) -> int:
16
+ output = args.output or args.input + ".wav"
17
+
18
+ data = open(args.input, 'rb').read()
19
+
20
+ waveform = encode_data(data)
21
+
22
+ if args.mono:
23
+ wavfile.write(output, SAMPLE_RATE, waveform)
24
+ else:
25
+ stereo = np.column_stack([waveform, -waveform])
26
+ wavfile.write(output, SAMPLE_RATE, stereo)
27
+
28
+ print(f"Encoded {len(data)} bytes into {output}")
29
+ print("Input CRC32:", zlib.crc32(data))
30
+ return 0
31
+
32
+
33
+ def decode_command(args) -> int:
34
+ output = args.output
35
+ if output is None:
36
+ base, ext = os.path.splitext(args.input)
37
+ output = base + ".out"
38
+
39
+ fs_in, rx = wavfile.read(args.input)
40
+ rx = rx.astype(np.float32)
41
+
42
+ if fs_in != SAMPLE_RATE:
43
+ print(f"Expected sample rate {SAMPLE_RATE}, got {fs_in}")
44
+ return 1
45
+
46
+ if rx.ndim == 2:
47
+ # Anti-phase stereo: subtracting the channels doubles the signal and cancels common noise
48
+ rx = (rx[:, 0] - rx[:, 1]) / 2
49
+
50
+ frames_payload, total_expected, stats = decode_data(rx, threads_num=args.threads, progress=True)
51
+
52
+ if total_expected is None:
53
+ print("No frames decoded.")
54
+ return 1
55
+
56
+ if args.fjf:
57
+ save_fjf(total_expected, frames_payload, args.fjf)
58
+ print(f"Saved {len(frames_payload)} frames into {args.fjf}")
59
+
60
+ data = reconstruct_data(frames_payload, total_expected)
61
+
62
+ open(output, 'wb').write(data)
63
+
64
+ print("Total frames expected:", total_expected)
65
+ print("Frames received:", len(frames_payload))
66
+ print(f"Decoded {len(data)} bytes into {output}")
67
+ print("Output CRC32:", zlib.crc32(data))
68
+
69
+ if len(frames_payload) < total_expected:
70
+ return 1
71
+ return 0
72
+
73
+
74
+ def main() -> int:
75
+ parser = argparse.ArgumentParser(prog="filejack", description="File encoding/decoding to audio signals.")
76
+ subparsers = parser.add_subparsers(dest="command", required=True)
77
+
78
+ encode_parser = subparsers.add_parser("encode", help="Encode a file into a WAV audio signal.")
79
+ encode_parser.add_argument("input", help="Path of the file to encode.")
80
+ encode_parser.add_argument("output", nargs='?', default=None, help="Path of the output WAV file. Defaults to <input>.wav")
81
+ encode_parser.add_argument("--mono", action="store_true", help="Write a mono WAV instead of anti-phase stereo.")
82
+ encode_parser.set_defaults(func=encode_command)
83
+
84
+ decode_parser = subparsers.add_parser("decode", help="Decode a WAV audio signal back into a file.")
85
+ decode_parser.add_argument("input", help="Path of the WAV file to decode.")
86
+ decode_parser.add_argument("output", nargs='?', default=None, help="Path of the output file. Defaults to <input without extension>.out")
87
+ decode_parser.add_argument("--fjf", default=None, help="Also save decoded frames into a .fjf file at this path.")
88
+ decode_parser.add_argument("--threads", type=int, default=12, help="Number of decoding threads. Defaults to 12.")
89
+ decode_parser.set_defaults(func=decode_command)
90
+
91
+ args = parser.parse_args()
92
+ return args.func(args)
93
+
94
+
95
+ if __name__ == '__main__':
96
+ raise SystemExit(main())