Skip to content

Commit 161b931

Browse files
committed
fix: implement multi-offset raw poll correlation for true sync preamble alignment
1 parent 380f61f commit 161b931

2 files changed

Lines changed: 85 additions & 37 deletions

File tree

src/hooks/useReceiver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export function useReceiver(): UseReceiverReturn {
7878
setStatus(s);
7979
switch (s.type) {
8080
case 'listening':
81-
addLog('Microphone access granted. Listening for handshake on 900Hz / 1050Hz...', 'info');
81+
addLog('Listening for handshake on 900Hz / 1050Hz...', 'info');
8282
break;
8383
case 'syncing':
8484
addLog('Handshake tone A (900Hz) detected! Waiting for tone B (1050Hz)...', 'success');

src/services/fskDecoder.ts

Lines changed: 84 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,63 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
183183
const MIN_HANDSHAKE_HOLD_MS = 80;
184184
let handshakeAHoldStart = 0;
185185

186-
// Sync preamble scanning
186+
// Sync preamble scanning — raw poll buffer approach.
187+
// We store every individual FFT poll result (not majority-voted), then
188+
// try all `pollsPerSymbol` possible phase offsets to find the one where
189+
// the majority-voted trits match the preamble pattern.
187190
let syncStartedAt = 0;
188-
const SYNC_TIMEOUT_MS = 10000; // Give up if preamble not found in 10s
191+
const SYNC_TIMEOUT_MS = 15000;
192+
const rawPolls: number[] = []; // individual per-poll dominant frequency indices
189193

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. */
194+
/** Majority-vote `count` polls starting at `start` in `rawPolls`. */
195+
function majorityVote(start: number, count: number): number {
196+
const freq: Record<number, number> = {};
197+
let validCount = 0;
198+
for (let i = start; i < start + count && i < rawPolls.length; i++) {
199+
if (rawPolls[i] >= 0) {
200+
freq[rawPolls[i]] = (freq[rawPolls[i]] ?? 0) + 1;
201+
validCount++;
202+
}
203+
}
204+
if (validCount === 0) return -1;
205+
return Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
206+
}
207+
208+
/**
209+
* Try all possible phase offsets (0..pollsPerSymbol-1) and check if
210+
* any of them produce the preamble pattern from the raw poll buffer.
211+
* Returns the winning offset, or -1 if no match.
212+
*/
213+
function findPreambleOffset(): number {
214+
const pLen = SYNC_PREAMBLE.length;
215+
const totalPollsNeeded = pLen * pollsPerSymbol;
216+
217+
// We need at least enough raw polls for the preamble + up to
218+
// (pollsPerSymbol - 1) extra for offset shifting.
219+
if (rawPolls.length < totalPollsNeeded) return -1;
220+
221+
// Try each offset, starting from the END of the buffer
222+
// (most recent polls = most likely to contain the preamble).
223+
for (let offset = 0; offset < pollsPerSymbol; offset++) {
224+
// Start from the latest possible position in the buffer.
225+
const baseStart = rawPolls.length - totalPollsNeeded - offset;
226+
if (baseStart < 0) continue;
227+
228+
let match = true;
229+
for (let sym = 0; sym < pLen; sym++) {
230+
const trit = majorityVote(baseStart + offset + sym * pollsPerSymbol, pollsPerSymbol);
231+
if (trit !== SYNC_PREAMBLE[sym]) {
232+
match = false;
233+
break;
234+
}
235+
}
236+
237+
if (match) return offset;
238+
}
239+
return -1;
240+
}
241+
242+
/** Majority-vote a symbol from the current vote bucket (used in COLLECTING). */
192243
function commitSymbol(): number {
193244
const valid = voteBucket.filter(t => t >= 0);
194245
voteBucket.length = 0;
@@ -198,16 +249,6 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
198249
return Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
199250
}
200251

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-
}
210-
211252
onStatus({ type: 'listening' });
212253

213254
// --- Main polling loop ---
@@ -235,12 +276,12 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
235276
const windowMs = (HANDSHAKE_TONE_DURATION_S + HANDSHAKE_SILENCE_S + HANDSHAKE_TONE_DURATION_S) * 1000 + 500;
236277

237278
if (detectHandshakeTone(fftData, sampleRate, HANDSHAKE_FREQ_B)) {
238-
// Go straight to SYNCING — we'll self-align via preamble scan.
239279
phase = 'SYNCING';
240280
syncStartedAt = Date.now();
281+
rawPolls.length = 0;
241282
voteBucket.length = 0;
242283
allTrits.length = 0;
243-
console.debug('[RX] Handshake B detected. Scanning for sync preamble...');
284+
console.debug('[RX] Handshake B detected. Scanning for sync preamble (multi-offset)...');
244285
onStatus({ type: 'receiving', chunk: 0, totalChunks: 0 });
245286
} else if (elapsed > windowMs) {
246287
console.debug(`[RX] Handshake B timeout after ${elapsed}ms. Resetting.`);
@@ -249,34 +290,41 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
249290
}
250291

251292
} 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).
293+
// Store every individual poll result in the raw buffer.
257294
const trit = detectFSKSymbol(fftData, sampleRate);
258-
voteBucket.push(trit);
295+
rawPolls.push(trit);
296+
297+
// Every few polls, try to find the preamble at any offset.
298+
if (rawPolls.length % pollsPerSymbol === 0) {
299+
const offset = findPreambleOffset();
300+
if (offset >= 0) {
301+
// Preamble found at this phase offset!
302+
phase = 'COLLECTING';
303+
voteBucket.length = 0;
304+
allTrits.length = 0;
305+
306+
// Pre-fill the vote bucket with any remaining raw polls
307+
// after the preamble ends, aligned to the discovered offset.
308+
// Any polls that arrived AFTER the preamble in the raw buffer
309+
// are already the start of data — but since we check every
310+
// pollsPerSymbol polls, there are typically 0 leftover.
311+
312+
console.debug(`[RX] ✅ Preamble found! Phase offset=${offset}, rawPolls=${rawPolls.length}. Data collection aligned.`);
313+
rawPolls.length = 0; // Free memory
314+
}
259315

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.
269-
allTrits.length = 0;
270-
phase = 'COLLECTING';
271-
console.debug('[RX] ✅ Sync preamble found! Data collection aligned.');
272-
}
316+
// Debug: log what the current best-effort looks like
317+
if (phase === 'SYNCING' && rawPolls.length % (pollsPerSymbol * 4) === 0) {
318+
const sample = rawPolls.slice(-pollsPerSymbol * 4).join(',');
319+
console.debug(`[RX][SYNC] ${rawPolls.length} polls. Recent: [${sample}]`);
273320
}
274321
}
275322

276-
// Timeout: if we never find the preamble, go back to listening.
323+
// Timeout: give up and reset.
277324
if (Date.now() - syncStartedAt > SYNC_TIMEOUT_MS) {
278325
console.debug('[RX] Sync preamble timeout. Resetting.');
279326
phase = 'WAITING_A';
327+
rawPolls.length = 0;
280328
allTrits.length = 0;
281329
onStatus({ type: 'listening' });
282330
}

0 commit comments

Comments
 (0)