filejack 1.0.0__py3-none-any.whl
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/AsyncDecodeManager.py +231 -0
- filejack/RSC.py +50 -0
- filejack/__init__.py +6 -0
- filejack/__main__.py +4 -0
- filejack/cli.py +96 -0
- filejack/conversions.py +23 -0
- filejack/decode_data.py +70 -0
- filejack/decode_frames.py +244 -0
- filejack/encode_frames.py +81 -0
- filejack/examples/__init__.py +0 -0
- filejack/examples/decoder.py +31 -0
- filejack/examples/encoder.py +18 -0
- filejack/examples/merge.py +24 -0
- filejack/examples/stereo_to_mono.py +22 -0
- filejack/merge_frames.py +59 -0
- filejack/reconstruct_data.py +12 -0
- filejack/values.py +32 -0
- filejack-1.0.0.dist-info/METADATA +150 -0
- filejack-1.0.0.dist-info/RECORD +23 -0
- filejack-1.0.0.dist-info/WHEEL +5 -0
- filejack-1.0.0.dist-info/entry_points.txt +2 -0
- filejack-1.0.0.dist-info/licenses/LICENSE +21 -0
- filejack-1.0.0.dist-info/top_level.txt +1 -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()
|
filejack/RSC.py
ADDED
|
@@ -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)))
|
filejack/__init__.py
ADDED
|
@@ -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
|
filejack/__main__.py
ADDED
filejack/cli.py
ADDED
|
@@ -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())
|
filejack/conversions.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from .values import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def bytes_to_symbols(buf: bytes) -> np.ndarray:
|
|
7
|
+
b = np.frombuffer(buf, dtype=np.uint8)
|
|
8
|
+
bits = np.unpackbits(b)
|
|
9
|
+
assert bits.size % BITS_PER_SYMBOL == 0
|
|
10
|
+
bit3 = bits.reshape(-1, BITS_PER_SYMBOL)
|
|
11
|
+
symbols = np.zeros((bit3.shape[0],), dtype=np.int32)
|
|
12
|
+
for i in range(BITS_PER_SYMBOL):
|
|
13
|
+
symbols += bit3[:, i] << (BITS_PER_SYMBOL - 1 - i)
|
|
14
|
+
return symbols.astype(np.int32)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def symbols_to_bytes(symbols: np.ndarray) -> bytes:
|
|
18
|
+
symbols = symbols.astype(np.uint8) % DAPSK
|
|
19
|
+
bits = np.empty(symbols.size * BITS_PER_SYMBOL, dtype=np.uint8)
|
|
20
|
+
for i in range(BITS_PER_SYMBOL):
|
|
21
|
+
bits[i::BITS_PER_SYMBOL] = (symbols >> (BITS_PER_SYMBOL - 1 - i)) & 1
|
|
22
|
+
packed = np.packbits(bits)
|
|
23
|
+
return packed.tobytes()
|
filejack/decode_data.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .decode_frames import decode_frames
|
|
6
|
+
from .values import *
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def decode_data(rx: np.ndarray, threads_num: int = 12, quick: bool = True, progress: bool = False):
|
|
10
|
+
"""
|
|
11
|
+
Decode all frames from a mono sample buffer. Splits the buffer into overlapping
|
|
12
|
+
blocks and decodes them in parallel threads.
|
|
13
|
+
Returns (frames_payload, total_expected, stats) where stats is a dict with
|
|
14
|
+
no_lock, rsc_error, header_error and crc_error counters.
|
|
15
|
+
"""
|
|
16
|
+
rx = np.asarray(rx, dtype=np.float32)
|
|
17
|
+
|
|
18
|
+
frames_payload = {}
|
|
19
|
+
stats = {'no_lock': 0, 'rsc_error': 0, 'header_error': 0, 'crc_error': 0}
|
|
20
|
+
total_expected = [None]
|
|
21
|
+
samples_done = [0]
|
|
22
|
+
limit = threading.BoundedSemaphore(threads_num)
|
|
23
|
+
lock = threading.Lock()
|
|
24
|
+
fs_block_size = SAMPLE_RATE * 5
|
|
25
|
+
|
|
26
|
+
def decode_block(block: np.ndarray):
|
|
27
|
+
try:
|
|
28
|
+
frames_block, total_expected1, ok_log1, no_lock1, rsc_error1, header_error1, crc_error1 = decode_frames(block, SAMPLE_RATE, search_step=SAMPLES_PER_SYMBOL, quick=quick)
|
|
29
|
+
with lock:
|
|
30
|
+
frames_payload.update(frames_block)
|
|
31
|
+
|
|
32
|
+
total_expected[0] = total_expected[0] or total_expected1
|
|
33
|
+
|
|
34
|
+
samples_done[0] += len(block) - int(SAMPLE_RATE * 0.2)
|
|
35
|
+
|
|
36
|
+
stats['no_lock'] += no_lock1
|
|
37
|
+
stats['rsc_error'] += rsc_error1
|
|
38
|
+
stats['header_error'] += header_error1
|
|
39
|
+
stats['crc_error'] += crc_error1
|
|
40
|
+
|
|
41
|
+
if progress:
|
|
42
|
+
print(f"\rBlock(Sample): {samples_done[0] // fs_block_size}/{len(rx) // fs_block_size}({samples_done[0]}/{len(rx)}) Frames: {len(frames_payload)}/{total_expected[0]} no_lock: {stats['no_lock']} rsc_error: {stats['rsc_error']} header_error: {stats['header_error']} crc_error: {stats['crc_error']}", end='')
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"\nError in thread: {e}")
|
|
45
|
+
finally:
|
|
46
|
+
limit.release()
|
|
47
|
+
|
|
48
|
+
threads: list[threading.Thread] = []
|
|
49
|
+
|
|
50
|
+
for i in range(0, len(rx), fs_block_size):
|
|
51
|
+
start = max(0, int(i - SAMPLE_RATE * 0.1))
|
|
52
|
+
end = min(len(rx), int(i + fs_block_size + SAMPLE_RATE * 0.1))
|
|
53
|
+
|
|
54
|
+
block = rx[start : end]
|
|
55
|
+
|
|
56
|
+
thread = threading.Thread(target=decode_block, args=(block,))
|
|
57
|
+
thread.start()
|
|
58
|
+
threads.append(thread)
|
|
59
|
+
limit.acquire()
|
|
60
|
+
|
|
61
|
+
threads = [t for t in threads if t.is_alive()]
|
|
62
|
+
|
|
63
|
+
while len(threads) > 0:
|
|
64
|
+
threads[0].join()
|
|
65
|
+
threads.pop(0)
|
|
66
|
+
|
|
67
|
+
if progress:
|
|
68
|
+
print()
|
|
69
|
+
|
|
70
|
+
return frames_payload, total_expected[0], stats
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import zlib
|
|
2
|
+
import numpy as np
|
|
3
|
+
from reedsolo import ReedSolomonError
|
|
4
|
+
from scipy.signal import hilbert
|
|
5
|
+
|
|
6
|
+
from .RSC import *
|
|
7
|
+
from .conversions import *
|
|
8
|
+
from .values import *
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def decode_frames(rx, sample_rate, search_step = 1, quick = False):
|
|
12
|
+
samples_per_symbol = int(round(sample_rate / baud_rate))
|
|
13
|
+
# sos_bp = butter(6, [carrier_freq - baud_rate * 2, carrier_freq + baud_rate * 2], btype='bandpass', fs=sample_rate, output='sos')
|
|
14
|
+
# rx = sosfiltfilt(sos_bp, rx).astype(np.float32)
|
|
15
|
+
|
|
16
|
+
pad_len = 2048
|
|
17
|
+
rx_padded = np.pad(rx, (pad_len, pad_len), 'constant')
|
|
18
|
+
|
|
19
|
+
analytic_padded = hilbert(rx_padded)
|
|
20
|
+
|
|
21
|
+
analytic = analytic_padded[pad_len:-pad_len]
|
|
22
|
+
|
|
23
|
+
num_symbols = int(len(rx) // samples_per_symbol)
|
|
24
|
+
n = np.arange(len(rx), dtype=np.int64)
|
|
25
|
+
t = n / sample_rate
|
|
26
|
+
bb = analytic * np.exp(-1j * 2*np.pi * carrier_freq * t)
|
|
27
|
+
|
|
28
|
+
# cut = 3500.0
|
|
29
|
+
# sos_lp = butter(6, cut, btype='lowpass', fs=sample_rate, output='sos')
|
|
30
|
+
# bb = sosfiltfilt(sos_lp, bb.real).astype(np.float32) + 1j * sosfiltfilt(sos_lp, bb.imag).astype(np.float32)
|
|
31
|
+
|
|
32
|
+
def symbols_from(bb, start_sample, offset, symbols_num):
|
|
33
|
+
start = start_sample + offset
|
|
34
|
+
need = start + symbols_num * samples_per_symbol
|
|
35
|
+
if need > len(bb):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
symbol = bb[start : start + symbols_num * samples_per_symbol].reshape(symbols_num, samples_per_symbol).sum(axis=1)
|
|
39
|
+
|
|
40
|
+
# phase = np.unwrap(np.angle(symbol))
|
|
41
|
+
# phase = np.angle(symbol)
|
|
42
|
+
# diff = np.diff(phase)
|
|
43
|
+
# diff = np.mod(diff, 2 * np.pi)
|
|
44
|
+
# diff = np.round(diff * PSK / 2 / np.pi).astype(np.int32) % PSK
|
|
45
|
+
|
|
46
|
+
return symbol
|
|
47
|
+
|
|
48
|
+
def steps_from(bb, start_sample, offset, n_symbols):
|
|
49
|
+
symbol = symbols_from(bb, start_sample, offset, n_symbols)
|
|
50
|
+
|
|
51
|
+
phase = np.angle(symbol[1:] * np.conj(symbol[:-1]))
|
|
52
|
+
diff = np.rint(np.mod(phase, 2*np.pi) * PSK/(2*np.pi)).astype(np.int32) % PSK
|
|
53
|
+
|
|
54
|
+
return diff
|
|
55
|
+
|
|
56
|
+
steps_by_offset = [steps_from(bb, 0, offset, (len(bb) - offset) // samples_per_symbol) for offset in range(samples_per_symbol)]
|
|
57
|
+
|
|
58
|
+
pattern = np.array(PREAMBLE_STEPS[1:] + SYNCWORD_STEPS, dtype=np.int32)
|
|
59
|
+
pattern_len = len(pattern)
|
|
60
|
+
|
|
61
|
+
PAYLOAD_STEPS_LEN = (N * 8) // BITS_PER_SYMBOL
|
|
62
|
+
|
|
63
|
+
frames_payload = {}
|
|
64
|
+
total_expected = None
|
|
65
|
+
min_match = int(0.9 * pattern_len)
|
|
66
|
+
|
|
67
|
+
no_lock = 0
|
|
68
|
+
rsc_error = 0
|
|
69
|
+
header_error = 0
|
|
70
|
+
crc_error = 0
|
|
71
|
+
ok_log = []
|
|
72
|
+
|
|
73
|
+
i = 0
|
|
74
|
+
while i + (pattern_len + PAYLOAD_STEPS_LEN + 1) * samples_per_symbol <= len(bb):
|
|
75
|
+
cands = []
|
|
76
|
+
for offset in range(samples_per_symbol):
|
|
77
|
+
# if i < offset:
|
|
78
|
+
# continue
|
|
79
|
+
|
|
80
|
+
start = max((i - offset) // samples_per_symbol, 0)
|
|
81
|
+
end = start + (pattern_len + PAYLOAD_STEPS_LEN + 1)
|
|
82
|
+
if end > len(steps_by_offset[offset]):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
steps = steps_by_offset[offset][start : end]
|
|
86
|
+
# steps = steps_from(bb, i, offset, pattern_len + PAYLOAD_STEPS_LEN + 1)
|
|
87
|
+
# if steps is None:
|
|
88
|
+
# continue
|
|
89
|
+
|
|
90
|
+
window = steps[:pattern_len]
|
|
91
|
+
|
|
92
|
+
delta = (window - pattern) % PSK
|
|
93
|
+
r = np.bincount(delta, minlength=PSK).argmax()
|
|
94
|
+
score = np.sum(delta == r)
|
|
95
|
+
|
|
96
|
+
if score >= min_match:
|
|
97
|
+
cands.append((score, offset, steps, start, r))
|
|
98
|
+
|
|
99
|
+
if not cands:
|
|
100
|
+
no_lock += 1
|
|
101
|
+
i += search_step
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
cands.sort(key=lambda x: x[0], reverse=True)
|
|
105
|
+
decoded_ok = False
|
|
106
|
+
for best_score, best_off, best_steps, best_start_idx, rotation in cands:
|
|
107
|
+
# Extract payload steps immediately after pattern
|
|
108
|
+
payload_steps = best_steps[pattern_len:pattern_len + PAYLOAD_STEPS_LEN]
|
|
109
|
+
payload_steps = (payload_steps - rotation) % PSK
|
|
110
|
+
|
|
111
|
+
symbols = symbols_from(bb, best_start_idx * samples_per_symbol, best_off, pattern_len + PAYLOAD_STEPS_LEN + 1)
|
|
112
|
+
|
|
113
|
+
gain = np.median(abs(symbols[2 : pattern_len + 1])) + 1e-12 # epsilon - avoid div by zero
|
|
114
|
+
a_hat = abs(symbols[pattern_len + 1: pattern_len + 1 + PAYLOAD_STEPS_LEN]) / gain
|
|
115
|
+
a_hat = np.clip(a_hat, 0.0, 1.0)
|
|
116
|
+
d = np.abs(a_hat[:, None] - ASK_LEVELS[None, :])
|
|
117
|
+
amplitudes = np.argmin(d, axis=1)
|
|
118
|
+
|
|
119
|
+
combined = (amplitudes.astype(np.uint8) << PSK_BITS_PER_SYMBOL) | payload_steps.astype(np.uint8)
|
|
120
|
+
cw = symbols_to_bytes(combined)
|
|
121
|
+
|
|
122
|
+
# RS decode
|
|
123
|
+
try:
|
|
124
|
+
decoded = RSC.decode(cw)[0]
|
|
125
|
+
except ReedSolomonError:
|
|
126
|
+
# print("\rReed-Solomon decoding failed, skipping unusable frame.")
|
|
127
|
+
# print(f"\rMalformed frame: {seq}/{total} {payload_len}/{MAX_PAYLOAD}")
|
|
128
|
+
|
|
129
|
+
rsc_error += 1
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
hdr = decoded[:HDR_LEN]
|
|
134
|
+
seq, total, payload_len = struct.unpack(HDR_FMT, hdr)
|
|
135
|
+
payload = decoded[HDR_LEN:HDR_LEN + payload_len]
|
|
136
|
+
|
|
137
|
+
if payload_len > MAX_PAYLOAD or (total_expected is not None and (total != total_expected or seq >= total_expected)):
|
|
138
|
+
header_error += 1
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
except struct.error:
|
|
142
|
+
header_error += 1
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
crc_recv = struct.unpack('>I', decoded[HDR_LEN + payload_len:HDR_LEN + payload_len + CRC_LEN])[0]
|
|
147
|
+
except struct.error:
|
|
148
|
+
print(f"\rFrame CRC unpacking failed: {seq}/{total_expected}")
|
|
149
|
+
print(decoded[HDR_LEN + payload_len:HDR_LEN + payload_len + CRC_LEN])
|
|
150
|
+
crc_error += 1
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
crc_calc = zlib.crc32(hdr + payload) & 0xFFFFFFFF
|
|
154
|
+
if crc_recv != crc_calc:
|
|
155
|
+
print(f"\rCRC mismatch for frame {seq}: received {crc_recv}, calculated {crc_calc}")
|
|
156
|
+
crc_error += 1
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
decoded_ok = True
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
if not decoded_ok:
|
|
163
|
+
i += search_step
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
try:
|
|
168
|
+
frames_payload[seq] = payload
|
|
169
|
+
total_expected = total_expected or total
|
|
170
|
+
|
|
171
|
+
frame_steps = pattern_len + PAYLOAD_STEPS_LEN + 1
|
|
172
|
+
frame_samples = frame_steps * samples_per_symbol
|
|
173
|
+
frame_start_sample = best_off + best_start_idx * samples_per_symbol
|
|
174
|
+
extra = 72
|
|
175
|
+
|
|
176
|
+
if len(ok_log) >= 2:
|
|
177
|
+
last_seq, last_start = ok_log[-1][0], ok_log[-1][1]
|
|
178
|
+
|
|
179
|
+
if last_seq < seq: # Normal forward progression
|
|
180
|
+
# Calculate actual spacing from last decoded frame
|
|
181
|
+
delta_seq = seq - last_seq
|
|
182
|
+
delta_samples = frame_start_sample - last_start
|
|
183
|
+
actual_spacing = delta_samples / delta_seq # samples per frame
|
|
184
|
+
|
|
185
|
+
# Predict next frame using observed spacing
|
|
186
|
+
expected = frame_start_sample + int(round(actual_spacing))
|
|
187
|
+
else:
|
|
188
|
+
# Fallback to nominal
|
|
189
|
+
expected = frame_start_sample + frame_samples + extra
|
|
190
|
+
else:
|
|
191
|
+
expected = frame_start_sample + frame_samples + extra
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
W = 16 * samples_per_symbol # try 4..8 symbols worth, start with 48 samples
|
|
195
|
+
|
|
196
|
+
best_local_score = -1
|
|
197
|
+
best_local_start = None
|
|
198
|
+
best_local_off = None
|
|
199
|
+
best_local_idx = None
|
|
200
|
+
best_local_rot = None
|
|
201
|
+
|
|
202
|
+
for cand_start_sample in range(expected - W, expected + W + 1):
|
|
203
|
+
if cand_start_sample < 0:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
off = cand_start_sample % samples_per_symbol
|
|
207
|
+
start_idx2 = cand_start_sample // samples_per_symbol
|
|
208
|
+
end2 = start_idx2 + (pattern_len + PAYLOAD_STEPS_LEN + 1)
|
|
209
|
+
|
|
210
|
+
if end2 > len(steps_by_offset[off]):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
steps2 = steps_by_offset[off][start_idx2:end2]
|
|
214
|
+
window2 = steps2[:pattern_len]
|
|
215
|
+
|
|
216
|
+
delta2 = (window2 - pattern) % PSK
|
|
217
|
+
r2 = np.bincount(delta2, minlength=PSK).argmax()
|
|
218
|
+
score2 = np.sum(delta2 == r2)
|
|
219
|
+
|
|
220
|
+
if score2 > best_local_score:
|
|
221
|
+
best_local_score = score2
|
|
222
|
+
best_local_start = cand_start_sample
|
|
223
|
+
best_local_off = off
|
|
224
|
+
best_local_idx = start_idx2
|
|
225
|
+
best_local_rot = r2
|
|
226
|
+
|
|
227
|
+
# snap if we found a good preamble near expected
|
|
228
|
+
if best_local_score >= min_match:
|
|
229
|
+
i = best_local_start
|
|
230
|
+
else:
|
|
231
|
+
if quick:
|
|
232
|
+
i = expected
|
|
233
|
+
else:
|
|
234
|
+
i += search_step
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ok_log.append((seq, frame_start_sample, expected, best_off, best_start_idx, best_score, rotation))
|
|
238
|
+
ok_log.append((seq, frame_start_sample, best_score))
|
|
239
|
+
|
|
240
|
+
except ValueError as e:
|
|
241
|
+
print(f"\rFrame parsing failed: {e}")
|
|
242
|
+
i += search_step
|
|
243
|
+
|
|
244
|
+
return frames_payload, total_expected, ok_log, no_lock, rsc_error, header_error, crc_error
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
import zlib
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .values import *
|
|
6
|
+
from .conversions import *
|
|
7
|
+
from .RSC import *
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_frames(data: bytes) -> list[bytearray]:
|
|
11
|
+
chunks = [data[i:i + MAX_PAYLOAD] for i in range(0, len(data), MAX_PAYLOAD)]
|
|
12
|
+
|
|
13
|
+
frames = []
|
|
14
|
+
for seq, payload in enumerate(chunks):
|
|
15
|
+
hdr = struct.pack(HDR_FMT, seq, len(chunks), len(payload))
|
|
16
|
+
crc = zlib.crc32(hdr + payload) & 0xFFFFFFFF
|
|
17
|
+
crc_b = struct.pack('>I', crc)
|
|
18
|
+
|
|
19
|
+
frame = hdr + payload + crc_b
|
|
20
|
+
frame += b"\x00" * (K - len(frame))
|
|
21
|
+
|
|
22
|
+
codeword = RSC.encode(frame)
|
|
23
|
+
frames.append(codeword)
|
|
24
|
+
return frames
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_frames(frames: list) -> np.ndarray:
|
|
28
|
+
if not frames:
|
|
29
|
+
return np.zeros(0, dtype=np.int16)
|
|
30
|
+
|
|
31
|
+
cursor = 0
|
|
32
|
+
all_samples = []
|
|
33
|
+
for cw in frames:
|
|
34
|
+
payload_steps = bytes_to_symbols(cw)
|
|
35
|
+
|
|
36
|
+
steps = np.concatenate([PREAMBLE_STEPS, SYNCWORD_STEPS, payload_steps % PSK])
|
|
37
|
+
steps = np.array(steps, dtype=np.int32)
|
|
38
|
+
|
|
39
|
+
diff = np.cumsum(steps) % PSK
|
|
40
|
+
amp_idx = (payload_steps / PSK).astype(np.int32)
|
|
41
|
+
|
|
42
|
+
amplitudes = ASK_LEVELS[amp_idx]
|
|
43
|
+
|
|
44
|
+
amplitudes = np.concatenate([np.full(len(PREAMBLE_STEPS), 1.0), np.full(len(SYNCWORD_STEPS), 1.0), amplitudes], dtype=np.float32)
|
|
45
|
+
|
|
46
|
+
phase_signal = np.repeat(diff, SAMPLES_PER_SYMBOL)
|
|
47
|
+
amplitudes = np.repeat(amplitudes, SAMPLES_PER_SYMBOL)
|
|
48
|
+
|
|
49
|
+
total_samples = len(phase_signal)
|
|
50
|
+
|
|
51
|
+
# Optional tiny fade-in to reduce click at frame boundary
|
|
52
|
+
fade_len = 2 * SAMPLES_PER_SYMBOL
|
|
53
|
+
|
|
54
|
+
ramp = np.linspace(0.0, 1.0, fade_len, endpoint=False, dtype=np.float32)
|
|
55
|
+
|
|
56
|
+
t_in = (np.arange(fade_len, dtype=np.int64) + cursor) / float(SAMPLE_RATE)
|
|
57
|
+
fade_in = float(amplitude) * np.cos(2 * np.pi * (carrier_freq * t_in + phase_signal[0] / PSK)) * ramp
|
|
58
|
+
cursor += fade_len
|
|
59
|
+
|
|
60
|
+
t = np.arange(total_samples, dtype=np.int64) + cursor
|
|
61
|
+
t = t / float(SAMPLE_RATE)
|
|
62
|
+
waveform = float(amplitude) * np.cos(2 * np.pi * (carrier_freq * t + phase_signal / PSK)) * amplitudes
|
|
63
|
+
cursor += total_samples
|
|
64
|
+
|
|
65
|
+
t_out = (np.arange(fade_len, dtype=np.int64) + cursor) / float(SAMPLE_RATE)
|
|
66
|
+
fade_out = float(amplitude) * np.cos(2 * np.pi * (carrier_freq * t_out + phase_signal[-1] / PSK)) * ramp[::-1]
|
|
67
|
+
cursor += fade_len
|
|
68
|
+
|
|
69
|
+
guard = np.zeros(fade_len, dtype=np.float32)
|
|
70
|
+
cursor += fade_len
|
|
71
|
+
|
|
72
|
+
waveform = np.concatenate([fade_in, waveform, fade_out, guard])
|
|
73
|
+
|
|
74
|
+
all_samples.append(waveform)
|
|
75
|
+
|
|
76
|
+
waveform = np.concatenate(all_samples)
|
|
77
|
+
return np.clip(np.rint(waveform), -32768, 32767).astype(np.int16)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encode_data(data: bytes) -> np.ndarray:
|
|
81
|
+
return encode_frames(build_frames(data))
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import zlib
|
|
2
|
+
from scipy.io import wavfile
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from filejack.decode_data import decode_data
|
|
6
|
+
from filejack.reconstruct_data import reconstruct_data
|
|
7
|
+
from filejack.merge_frames import save_fjf
|
|
8
|
+
from filejack.values import *
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
file = "7z6"
|
|
12
|
+
|
|
13
|
+
fs_in, rx = wavfile.read(f"{file}.wav")
|
|
14
|
+
rx = rx.astype(np.float32)
|
|
15
|
+
|
|
16
|
+
assert fs_in == SAMPLE_RATE, f"Expected sample rate {SAMPLE_RATE}, got {fs_in}"
|
|
17
|
+
|
|
18
|
+
if rx.ndim == 2:
|
|
19
|
+
rx = (rx[:, 0] - rx[:, 1]) / 2
|
|
20
|
+
|
|
21
|
+
frames_payload, total_expected, stats = decode_data(rx, threads_num=12, progress=True)
|
|
22
|
+
|
|
23
|
+
save_fjf(total_expected, frames_payload, f"{file}.fjf")
|
|
24
|
+
|
|
25
|
+
data = reconstruct_data(frames_payload, total_expected)
|
|
26
|
+
|
|
27
|
+
open(f"out.{file}", 'wb').write(data)
|
|
28
|
+
|
|
29
|
+
print("Total frames expected:", total_expected)
|
|
30
|
+
print("Frames received:", len(frames_payload))
|
|
31
|
+
print("Output CRC32: ", zlib.crc32(data))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import zlib
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.io import wavfile
|
|
4
|
+
|
|
5
|
+
from filejack.encode_frames import encode_data
|
|
6
|
+
from filejack.values import *
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
file = "bmp"
|
|
10
|
+
|
|
11
|
+
data = open(f"in.{file}", 'rb').read()
|
|
12
|
+
|
|
13
|
+
waveform = encode_data(data)
|
|
14
|
+
|
|
15
|
+
stereo = np.column_stack([waveform, -waveform])
|
|
16
|
+
wavfile.write(f"{file}.wav", SAMPLE_RATE, stereo)
|
|
17
|
+
|
|
18
|
+
print(zlib.crc32(data))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from filejack.merge_frames import merge_frames, load_fjf, save_fjf
|
|
2
|
+
from filejack.reconstruct_data import reconstruct_data
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
path1 = "merged.fjf"
|
|
7
|
+
path2 = "decode3.fjf"
|
|
8
|
+
output_path = "merged.fjf"
|
|
9
|
+
|
|
10
|
+
total_expected, frames1 = load_fjf(path1)
|
|
11
|
+
total_expected, frames2 = load_fjf(path2)
|
|
12
|
+
|
|
13
|
+
merged_frames = merge_frames(total_expected, [frames1, frames2])
|
|
14
|
+
# merged_frames = merge_frames([frames2, frames1]) # Try reversing order to prefer frames from second file
|
|
15
|
+
|
|
16
|
+
save_fjf(total_expected, merged_frames, output_path)
|
|
17
|
+
print(f"Merged {len(frames1)} frames from {path1} and {len(frames2)} frames from {path2} into {len(merged_frames)} frames in {output_path}")
|
|
18
|
+
|
|
19
|
+
data = reconstruct_data(merged_frames, total_expected)
|
|
20
|
+
open("reconstructed.7z", "wb").write(data)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == '__main__':
|
|
24
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert a stereo WAV file to mono by subtracting the right channel from the left channel. Should be used with encoder/decoder to cancel out the noise in stereo-jack transfers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.io import wavfile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
sample_rate, rx = wavfile.read(f"stereo.wav")
|
|
11
|
+
|
|
12
|
+
stereo = np.array(rx).astype(np.float32)
|
|
13
|
+
mono = stereo[:, 0] - stereo[:, 1]
|
|
14
|
+
mono = mono / 2
|
|
15
|
+
|
|
16
|
+
mono = np.clip(np.rint(mono), -32768, 32767).astype(np.int16)
|
|
17
|
+
|
|
18
|
+
wavfile.write(f"mono.wav", sample_rate, mono)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == '__main__':
|
|
22
|
+
main()
|
filejack/merge_frames.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.fjf files is FileJack Frames file, containing multiple decoded frames with metadata. Can be used to merge results from different decoding attempts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
FILEJACK_FJF_HEADER = b'FJF0917\nhttps://github.com/Staheos/filejack\n1.0.0'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def merge_frames(total_expected: int, frames_inputs: list[dict[int, bytes | bytearray | list[int]]]):
|
|
12
|
+
frames_output = {}
|
|
13
|
+
|
|
14
|
+
for frames_input in frames_inputs:
|
|
15
|
+
for seq, payload in frames_input.items():
|
|
16
|
+
if seq not in frames_output:
|
|
17
|
+
frames_output[seq] = payload
|
|
18
|
+
return frames_output
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_fjf(total_expected: int, frames: dict[int, bytes | bytearray | list[int]], filename: str):
|
|
22
|
+
with open(filename, 'wb') as f:
|
|
23
|
+
|
|
24
|
+
f.write(FILEJACK_FJF_HEADER)
|
|
25
|
+
f.write(struct.pack('>I', total_expected))
|
|
26
|
+
f.write(struct.pack('>I', len(frames)))
|
|
27
|
+
|
|
28
|
+
for seq in sorted(frames.keys()):
|
|
29
|
+
payload = frames[seq]
|
|
30
|
+
if isinstance(payload, list):
|
|
31
|
+
payload = bytes(payload)
|
|
32
|
+
payload_len = len(payload)
|
|
33
|
+
f.write(struct.pack('>I', seq)) # Frame sequence number
|
|
34
|
+
f.write(struct.pack('>I', payload_len)) # Payload length
|
|
35
|
+
f.write(payload) # Payload data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_fjf(filename: str) -> tuple[int, dict[int, bytes]]:
|
|
39
|
+
frames = {}
|
|
40
|
+
with open(filename, 'rb') as f:
|
|
41
|
+
# Read header
|
|
42
|
+
header = f.read(len(FILEJACK_FJF_HEADER))
|
|
43
|
+
if header != FILEJACK_FJF_HEADER:
|
|
44
|
+
raise ValueError("Invalid FJF file format")
|
|
45
|
+
|
|
46
|
+
total_expected_bytes = f.read(4)
|
|
47
|
+
total_expected = struct.unpack('>I', total_expected_bytes)[0]
|
|
48
|
+
|
|
49
|
+
num_frames_bytes = f.read(4)
|
|
50
|
+
num_frames = struct.unpack('>I', num_frames_bytes)[0]
|
|
51
|
+
|
|
52
|
+
for _ in range(num_frames):
|
|
53
|
+
seq_bytes = f.read(4)
|
|
54
|
+
payload_len_bytes = f.read(4)
|
|
55
|
+
seq = struct.unpack('>I', seq_bytes)[0]
|
|
56
|
+
payload_len = struct.unpack('>I', payload_len_bytes)[0]
|
|
57
|
+
payload = f.read(payload_len)
|
|
58
|
+
frames[seq] = payload
|
|
59
|
+
return total_expected, frames
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .RSC import MAX_PAYLOAD
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def reconstruct_data(frames_payload, frames_num) -> bytes:
|
|
5
|
+
data = bytearray()
|
|
6
|
+
for i in range(0, frames_num):
|
|
7
|
+
if i not in frames_payload:
|
|
8
|
+
print(f"Missing frame {i}")
|
|
9
|
+
data.extend(b'\x00' * MAX_PAYLOAD)
|
|
10
|
+
continue
|
|
11
|
+
data.extend(frames_payload[i])
|
|
12
|
+
return bytes(data)
|
filejack/values.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
PSK = 8
|
|
5
|
+
PSK_BITS_PER_SYMBOL = int(math.log2(PSK))
|
|
6
|
+
assert 2 ** PSK_BITS_PER_SYMBOL == PSK
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ASK_RINGS = 1
|
|
10
|
+
ASK_BITS_PER_SYMBOL = int(math.log2(ASK_RINGS))
|
|
11
|
+
assert 2 ** ASK_BITS_PER_SYMBOL == ASK_RINGS
|
|
12
|
+
|
|
13
|
+
if ASK_RINGS == 1:
|
|
14
|
+
ASK_LEVELS = np.array([1])
|
|
15
|
+
else:
|
|
16
|
+
ASK_LEVELS = np.linspace(0.4, 1.0, ASK_RINGS, dtype=np.float32)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DAPSK = PSK * ASK_RINGS
|
|
20
|
+
BITS_PER_SYMBOL = PSK_BITS_PER_SYMBOL + ASK_BITS_PER_SYMBOL
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SAMPLE_RATE = 48000
|
|
24
|
+
baud_rate = 4000
|
|
25
|
+
amplitude = int(32767 * 0.8)
|
|
26
|
+
carrier_freq = 12000
|
|
27
|
+
|
|
28
|
+
SAMPLES_PER_SYMBOL = int(round(SAMPLE_RATE / baud_rate))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
PREAMBLE_SYMS = 64
|
|
32
|
+
SYNCWORD_SYMS = 32
|
|
@@ -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,23 @@
|
|
|
1
|
+
filejack/AsyncDecodeManager.py,sha256=g7-omESfySca1zNjFillfJ4AChBatExqd8UiLXwEPMk,6667
|
|
2
|
+
filejack/RSC.py,sha256=qD7XCWE82uJHTl6mjcKks-NUToxZZG88zjHydc6MOx8,1023
|
|
3
|
+
filejack/__init__.py,sha256=ZVeR32bA--Dy2akb_i2kGeskFSLR3n9TqKs4SK1bp3Y,312
|
|
4
|
+
filejack/__main__.py,sha256=D31U8_ux95qF64EQ8ReT25nabzxh7ped5avpobXz_bM,49
|
|
5
|
+
filejack/cli.py,sha256=y3EKLSLQpanJ0lQQuLlO9zXZHBLWNSbXacNpuhGbvSU,3142
|
|
6
|
+
filejack/conversions.py,sha256=rp0t9ZHTUJsMG9hvkYDHMcESaGfib1v1oaOjggRfckg,736
|
|
7
|
+
filejack/decode_data.py,sha256=zsBqw80kpDUhkb3dvLfvQ5TbNIKHXAYeKzMxMKMc9RM,2263
|
|
8
|
+
filejack/decode_frames.py,sha256=QvTw7nkc7kHVPFn8793-kcCiA4XrM2DeC3FPn6fyaQM,7587
|
|
9
|
+
filejack/encode_frames.py,sha256=SW-uM7yjtAZMbsN5QReDXzGuGAdCT5z8SjHifBZTDXo,2468
|
|
10
|
+
filejack/merge_frames.py,sha256=lfb86_hYjmFO0QjtQ_qbrGl7EqmgSezGrgr_yioAzuw,1827
|
|
11
|
+
filejack/reconstruct_data.py,sha256=AlD887NndfYXHui2Hp4D69X_2GzNUHIOM57a2N100jc,305
|
|
12
|
+
filejack/values.py,sha256=MOe7cRg8jNACeOe-e5tIzK2kmhIz_kBkdocXR4RiIgg,622
|
|
13
|
+
filejack/examples/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
filejack/examples/decoder.py,sha256=25bYNzR53FWyjA7ls81V6FQa7kMe_c2NOgrWmvhs4Dk,827
|
|
15
|
+
filejack/examples/encoder.py,sha256=k5tO3xe72lC703oUKT7-RY92EvKIj1m7hP7lZqJ89jg,347
|
|
16
|
+
filejack/examples/merge.py,sha256=7lNOBo5WNgDRaSNrU1blA5dWgzDZZ0IZcfQbSZh4N_0,807
|
|
17
|
+
filejack/examples/stereo_to_mono.py,sha256=PBgPaiDwSTPyMjAJ18b7XmkFyEAgmdXe05SV5kQS7B0,539
|
|
18
|
+
filejack-1.0.0.dist-info/licenses/LICENSE,sha256=C-SikHBZl70gMFwjvC9EqIKe4ki3pK0QM3L4Avm1MD8,1064
|
|
19
|
+
filejack-1.0.0.dist-info/METADATA,sha256=It0PBjUtDg-hxaPMXi1htnEpgmGnSbDfPfLJJ2vGOJg,4750
|
|
20
|
+
filejack-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
21
|
+
filejack-1.0.0.dist-info/entry_points.txt,sha256=sotGg7_o1BuSJOl4llcQ2ypWQ9bhFntHMHfZlyF4ocQ,47
|
|
22
|
+
filejack-1.0.0.dist-info/top_level.txt,sha256=67ReEJaubGZkfcf0WlBmxj3zoLXwoppOd-7AzbgqtlM,9
|
|
23
|
+
filejack-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
filejack
|