Skip to content

Commit 380f61f

Browse files
committed
feat: replace phase-lock timing with sync preamble protocol for reliable FSK reception
1 parent de0dec6 commit 380f61f

3 files changed

Lines changed: 97 additions & 84 deletions

File tree

src/services/fskDecoder.ts

Lines changed: 79 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
RX_DETECTION_THRESHOLD,
2828
FFT_SIZE,
2929
RX_POLL_INTERVAL_MS,
30+
SYNC_PREAMBLE,
3031
tritsToBytes,
3132
bytesToText,
3233
parsePacket,
@@ -160,29 +161,52 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
160161
const sampleRate = ctx.sampleRate;
161162

162163
// --- State machine ---
163-
// WAITING_A → tone A confirmed
164-
// WAITING_B → tone B first detected
165-
// LOCKING → waiting for tone B to DISAPPEAR (so we know when data actually starts)
166-
// ALIGNING → silence gap: a setTimeout will flip us to COLLECTING at the right moment
167-
// COLLECTING → voting on FSK symbols
164+
// WAITING_A → handshake tone A detected (hold-confirmed)
165+
// WAITING_B → handshake tone B first detected
166+
// SYNCING → collecting trits and scanning for preamble pattern [7,0,7,0,7,0,7,0]
167+
// COLLECTING → preamble found; all subsequent trits are data; packet assembly
168168
// DONE → all packets received
169-
type Phase = 'WAITING_A' | 'WAITING_B' | 'LOCKING' | 'ALIGNING' | 'COLLECTING' | 'DONE';
169+
type Phase = 'WAITING_A' | 'WAITING_B' | 'SYNCING' | 'COLLECTING' | 'DONE';
170170
let phase: Phase = 'WAITING_A';
171171

172-
// Symbol accumulation
172+
// Symbol accumulation (shared by SYNCING and COLLECTING)
173173
const pollsPerSymbol = Math.round((SYMBOL_DURATION_S * 1000) / RX_POLL_INTERVAL_MS);
174-
const voteBucket: number[] = []; // accumulated trit votes within one symbol window
175-
const allTrits: number[] = []; // ordered trits for the full transmission
174+
const voteBucket: number[] = [];
175+
const allTrits: number[] = [];
176176

177177
// Packet reassembly
178-
const receivedPackets = new Map<number, Uint8Array>(); // chunkIndex → payload
178+
const receivedPackets = new Map<number, Uint8Array>();
179179
let totalExpectedChunks = -1;
180180

181-
// Handshake phase gating variables.
181+
// Handshake gating
182182
let handshakeADetectedAt = 0;
183-
const MIN_HANDSHAKE_HOLD_MS = 80; // Require tone A for ≥2 polls before accepting
183+
const MIN_HANDSHAKE_HOLD_MS = 80;
184184
let handshakeAHoldStart = 0;
185-
let lockingAbsentRuns = 0; // Consecutive polls where tone B is absent (LOCKING phase)
185+
186+
// Sync preamble scanning
187+
let syncStartedAt = 0;
188+
const SYNC_TIMEOUT_MS = 10000; // Give up if preamble not found in 10s
189+
190+
/** Majority-vote a symbol from the current vote bucket. Returns the
191+
* winning trit (0..7) or -1 if no valid votes were recorded. */
192+
function commitSymbol(): number {
193+
const valid = voteBucket.filter(t => t >= 0);
194+
voteBucket.length = 0;
195+
if (valid.length === 0) return -1;
196+
const freq: Record<number, number> = {};
197+
for (const v of valid) freq[v] = (freq[v] ?? 0) + 1;
198+
return Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
199+
}
200+
201+
/** Check if the last N trits in `allTrits` match the SYNC_PREAMBLE pattern. */
202+
function preambleMatched(): boolean {
203+
const len = SYNC_PREAMBLE.length;
204+
if (allTrits.length < len) return false;
205+
for (let i = 0; i < len; i++) {
206+
if (allTrits[allTrits.length - len + i] !== SYNC_PREAMBLE[i]) return false;
207+
}
208+
return true;
209+
}
186210

187211
onStatus({ type: 'listening' });
188212

@@ -199,105 +223,86 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
199223
phase = 'WAITING_B';
200224
handshakeADetectedAt = Date.now();
201225
handshakeAHoldStart = 0;
202-
console.debug('[RX] Handshake A confirmed (900 Hz). Waiting for B (1050 Hz)...');
226+
console.debug('[RX] Handshake A confirmed (900 Hz). Waiting for B...');
203227
onStatus({ type: 'syncing' });
204228
}
205229
} else {
206230
handshakeAHoldStart = 0;
207231
}
208232

209233
} else if (phase === 'WAITING_B') {
210-
// Window: generous enough to span the full A+silence+B duration.
211234
const elapsed = Date.now() - handshakeADetectedAt;
212-
const windowMs = (HANDSHAKE_TONE_DURATION_S + HANDSHAKE_SILENCE_S + HANDSHAKE_TONE_DURATION_S) * 1000 + 400;
235+
const windowMs = (HANDSHAKE_TONE_DURATION_S + HANDSHAKE_SILENCE_S + HANDSHAKE_TONE_DURATION_S) * 1000 + 500;
213236

214237
if (detectHandshakeTone(fftData, sampleRate, HANDSHAKE_FREQ_B)) {
215-
// Tone B is now audible. Move to LOCKING so we can detect
216-
// when it ENDS — that's when we know data is about to start.
217-
phase = 'LOCKING';
218-
lockingAbsentRuns = 0;
219-
console.debug('[RX] Handshake B detected (1050 Hz). Locking on tone end...');
238+
// Go straight to SYNCING — we'll self-align via preamble scan.
239+
phase = 'SYNCING';
240+
syncStartedAt = Date.now();
241+
voteBucket.length = 0;
242+
allTrits.length = 0;
243+
console.debug('[RX] Handshake B detected. Scanning for sync preamble...');
244+
onStatus({ type: 'receiving', chunk: 0, totalChunks: 0 });
220245
} else if (elapsed > windowMs) {
221246
console.debug(`[RX] Handshake B timeout after ${elapsed}ms. Resetting.`);
222247
phase = 'WAITING_A';
223248
onStatus({ type: 'listening' });
224249
}
225250

226-
} else if (phase === 'LOCKING') {
227-
// Wait for tone B to disappear, then arm a timed delay equal to
228-
// the post-handshake silence gap. When that fires, collection
229-
// starts phase-locked to the first data symbol boundary.
230-
const elapsed = Date.now() - handshakeADetectedAt;
251+
} else if (phase === 'SYNCING') {
252+
// Collect trits via majority vote, then scan for the preamble
253+
// pattern. The preamble is an alternating [7,0,7,0...] sequence
254+
// that can ONLY match when the vote windows are aligned to the
255+
// transmitter's symbol boundaries (misaligned windows see
256+
// intermediate values like 3, 4, 2... not clean 7s and 0s).
257+
const trit = detectFSKSymbol(fftData, sampleRate);
258+
voteBucket.push(trit);
231259

232-
if (!detectHandshakeTone(fftData, sampleRate, HANDSHAKE_FREQ_B)) {
233-
lockingAbsentRuns++;
234-
if (lockingAbsentRuns >= 2) {
235-
// Tone B has definitely ended. Schedule COLLECTING to
236-
// start after the encoder's silence gap.
237-
phase = 'ALIGNING';
238-
console.debug(`[RX] Tone B ended. Waiting ${Math.round(HANDSHAKE_SILENCE_S * 1000)}ms silence gap then collecting...`);
239-
setTimeout(() => {
240-
if (stopped || phase !== 'ALIGNING') return;
241-
voteBucket.length = 0;
260+
if (voteBucket.length >= pollsPerSymbol) {
261+
const winner = commitSymbol();
262+
if (winner >= 0) {
263+
allTrits.push(winner);
264+
console.debug(`[RX][SYNC] trit=${winner}, buffer=[...${allTrits.slice(-SYNC_PREAMBLE.length).join(',')}]`);
265+
266+
if (preambleMatched()) {
267+
// Preamble found! Discard everything (it was all preamble
268+
// or junk before the preamble). Data starts NOW.
242269
allTrits.length = 0;
243270
phase = 'COLLECTING';
244-
console.debug('[RX] Phase-locked! Data collection started.');
245-
onStatus({ type: 'receiving', chunk: 0, totalChunks: 0 });
246-
}, HANDSHAKE_SILENCE_S * 1000);
271+
console.debug('[RX] ✅ Sync preamble found! Data collection aligned.');
272+
}
247273
}
248-
} else {
249-
lockingAbsentRuns = 0; // Still hearing B — keep waiting
250274
}
251275

252-
// Global safety: if something went wrong, reset after 5 s
253-
if (elapsed > 5000) {
276+
// Timeout: if we never find the preamble, go back to listening.
277+
if (Date.now() - syncStartedAt > SYNC_TIMEOUT_MS) {
278+
console.debug('[RX] Sync preamble timeout. Resetting.');
254279
phase = 'WAITING_A';
280+
allTrits.length = 0;
255281
onStatus({ type: 'listening' });
256282
}
257283

258-
} else if (phase === 'ALIGNING') {
259-
// Waiting for the setTimeout to fire — do nothing.
260-
261284
} else if (phase === 'COLLECTING') {
262285
const trit = detectFSKSymbol(fftData, sampleRate);
263-
voteBucket.push(trit); // -1 = silence/noise counts as no signal
286+
voteBucket.push(trit);
264287

265-
// Once we have enough votes for one symbol window, commit.
266288
if (voteBucket.length >= pollsPerSymbol) {
267-
// Majority vote among non-negative results.
268-
const valid = voteBucket.filter(t => t >= 0);
269-
if (valid.length > 0) {
270-
const freq: Record<number, number> = {};
271-
for (const v of valid) freq[v] = (freq[v] ?? 0) + 1;
272-
const winner = Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
289+
const winner = commitSymbol();
290+
if (winner >= 0) {
273291
allTrits.push(winner);
274-
console.debug(`[RX] Symbol committed: trit=${winner} (${valid.length}/${voteBucket.length} valid votes). Total trits: ${allTrits.length}`);
275-
} else {
276-
console.debug(`[RX] Symbol window: no valid votes (all silence/noise). Total trits: ${allTrits.length}`);
292+
console.debug(`[RX] Symbol: trit=${winner}, total=${allTrits.length}`);
277293
}
278-
voteBucket.length = 0;
279294

280-
// Minimum trits needed for the smallest possible packet
281-
// (header + 1 payload byte + CRC).
295+
// Minimum trits for smallest possible packet.
282296
const minTritsForMinPacket = Math.ceil(
283297
((PACKET_HEADER_BYTES + 1 + PACKET_CRC_BYTES) * 8) / 3
284298
);
285-
// Maximum trits for a full-size packet.
286299
const maxTritsPerPacket = Math.ceil(
287300
((PACKET_HEADER_BYTES + MAX_PAYLOAD_BYTES + PACKET_CRC_BYTES) * 8) / 3
288301
);
289302

290303
if (allTrits.length >= minTritsForMinPacket) {
291-
// Sliding-window scan: try parsing from offset 0; on CRC failure,
292-
// advance by 1 trit and retry until we find a valid packet or
293-
// exhaust the reasonable search window.
294-
//
295-
// This handles timing drift and minor bit errors by discarding
296-
// corrupted leading trits rather than deadlocking.
304+
// Sliding-window scan for a valid packet.
297305
let foundPacket = false;
298-
299-
// Only scan up to maxTritsPerPacket ahead from the start
300-
// to avoid re-scanning a packet we already consumed.
301306
const scanLimit = Math.min(
302307
allTrits.length - minTritsForMinPacket + 1,
303308
maxTritsPerPacket
@@ -312,34 +317,29 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
312317
const packet = parsePacket(raw);
313318

314319
if (packet && packet.crcValid) {
315-
console.debug(`[RX] Valid packet found at trit offset ${offset}: chunk ${packet.chunkIndex + 1}/${packet.totalChunks}, payload=${packet.payload.length}B`);
320+
console.debug(`[RX] Valid packet at offset ${offset}: chunk ${packet.chunkIndex + 1}/${packet.totalChunks}, ${packet.payload.length}B`);
316321

317322
if (!receivedPackets.has(packet.chunkIndex)) {
318323
totalExpectedChunks = packet.totalChunks;
319324
receivedPackets.set(packet.chunkIndex, packet.payload);
320-
321325
onStatus({
322326
type: 'receiving',
323327
chunk: receivedPackets.size,
324328
totalChunks: totalExpectedChunks,
325329
});
326330
}
327331

328-
// Discard all trits up to and including this packet.
329332
const tritsConsumed = offset + Math.ceil(
330333
((PACKET_HEADER_BYTES + packet.payload.length + PACKET_CRC_BYTES) * 8) / 3
331334
);
332335
allTrits.splice(0, tritsConsumed);
333336
foundPacket = true;
334337

335-
// Check if we have all chunks.
336-
if (
337-
totalExpectedChunks > 0 &&
338-
receivedPackets.size >= totalExpectedChunks
339-
) {
338+
if (totalExpectedChunks > 0 && receivedPackets.size >= totalExpectedChunks) {
340339
phase = 'DONE';
341340
const reconstructed = reassemble(receivedPackets, totalExpectedChunks);
342341
const text = bytesToText(reconstructed);
342+
console.debug(`[RX] ✅ Complete! Decoded: "${text}"`);
343343
onStatus({ type: 'complete', text });
344344
stop();
345345
}
@@ -348,10 +348,7 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
348348
}
349349

350350
if (!foundPacket && allTrits.length > maxTritsPerPacket * 2) {
351-
// We've accumulated way more trits than one packet needs
352-
// and still no valid parse — drop the oldest trit to avoid
353-
// an ever-growing buffer (graceful degradation).
354-
console.debug(`[RX] Buffer too large (${allTrits.length} trits), discarding 1 leading trit.`);
351+
console.debug(`[RX] Buffer overflow (${allTrits.length} trits), dropping 1.`);
355352
allTrits.shift();
356353
}
357354
}

src/services/fskEncoder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
EOT_FREQ,
2222
EOT_DURATION_S,
2323
TX_AMPLITUDE,
24+
SYNC_PREAMBLE,
2425
textToBytes,
2526
bytesToTrits,
2627
chunkData,
@@ -132,6 +133,13 @@ export function transmitText(text: string, onStatus: TxStatusCallback): () => vo
132133
// Global handshake at the very start.
133134
cursor = scheduleHandshake(ctx, gainNode, cursor);
134135

136+
// Sync preamble: alternating max/min tones that the receiver
137+
// scans for to self-synchronize its symbol vote windows.
138+
for (const trit of SYNC_PREAMBLE) {
139+
playTone(ctx, gainNode, FSK_FREQUENCIES[trit], cursor, SYMBOL_DURATION_S);
140+
cursor += SYMBOL_DURATION_S;
141+
}
142+
135143
// Schedule each packet with UI callback timing.
136144
for (let i = 0; i < packets.length; i++) {
137145
if (aborted) break;

src/services/protocol.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,16 @@ export const HANDSHAKE_FREQ_B = 1050; // Hz
5050
// 350 ms gives the receiver ~8-9 polls to confirm the tone (robust over acoustic path).
5151
export const HANDSHAKE_TONE_DURATION_S = 0.35;
5252

53-
// Silence gap between handshake tones (seconds).
54-
export const HANDSHAKE_SILENCE_S = 0.15;
53+
// Silence gap between handshake tones and before preamble (seconds).
54+
export const HANDSHAKE_SILENCE_S = 0.25;
55+
56+
// --- Sync Preamble ---
57+
// Sent immediately after the handshake, before actual data.
58+
// The receiver scans for this alternating max/min pattern to self-synchronize
59+
// its symbol vote windows with the transmitter's symbol boundaries.
60+
// This is the standard technique used in real FSK protocols (UART start bits,
61+
// modem training sequences, etc.).
62+
export const SYNC_PREAMBLE: readonly number[] = [7, 0, 7, 0, 7, 0, 7, 0];
5563

5664
// --- End-of-Transmission Tone ---
5765
export const EOT_FREQ = 700; // Hz (below FSK range — unambiguous)

0 commit comments

Comments
 (0)