Skip to content

Commit aed5ec8

Browse files
committed
fix: time-domain acoustic preamble correlator to eliminate JS timer drift
1 parent 2b057f4 commit aed5ec8

2 files changed

Lines changed: 149 additions & 137 deletions

File tree

src/services/fskDecoder.ts

Lines changed: 148 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,6 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
170170
let phase: Phase = 'WAITING_A';
171171

172172
// Symbol accumulation (shared by SYNCING and COLLECTING)
173-
const pollsPerSymbol = Math.round((SYMBOL_DURATION_S * 1000) / RX_POLL_INTERVAL_MS);
174-
const voteBucket: number[] = [];
175173
const allTrits: number[] = [];
176174

177175
// Packet reassembly
@@ -183,64 +181,81 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
183181
const MIN_HANDSHAKE_HOLD_MS = 80;
184182
let handshakeAHoldStart = 0;
185183

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.
184+
// --- Time-domain Preamble Correlator ---
185+
interface PollData {
186+
time: number;
187+
trit: number;
188+
}
189+
const syncPolls: PollData[] = [];
190190
let syncStartedAt = 0;
191191
const SYNC_TIMEOUT_MS = 15000;
192-
const rawPolls: number[] = []; // individual per-poll dominant frequency indices
193192

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-
}
193+
// --- Time-based Data Collection ---
194+
let dataStartTime = 0;
195+
let currentSymbolIndex = 0;
196+
const voteBucket: number[] = [];
207197

208198
/**
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.
199+
* Sweeps the acoustic timestamps in 10ms steps across the raw poll buffer
200+
* to find the exact start time (t0) where the 8 preamble symbols align.
212201
*/
213-
function findPreambleOffset(): number {
202+
function findPreambleStartTime(): number {
203+
if (syncPolls.length === 0) return -1;
204+
214205
const pLen = SYNC_PREAMBLE.length;
215-
const totalPollsNeeded = pLen * pollsPerSymbol;
206+
const pDuration = pLen * SYMBOL_DURATION_S;
207+
const firstTime = syncPolls[0].time;
208+
const lastTime = syncPolls[syncPolls.length - 1].time;
216209

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;
210+
// Wait until we have enough duration to contain the preamble
211+
if (lastTime - firstTime < pDuration) return -1;
220212

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;
213+
let bestT0 = -1;
214+
let bestScore = -1;
227215

228-
const votedPattern: number[] = [];
229-
let match = true;
216+
// Sweep t0 across all possible start times in the buffer
217+
for (let t0 = firstTime; t0 <= lastTime - pDuration; t0 += 0.01) {
218+
let allMatched = true;
219+
let score = 0;
230220

231221
for (let sym = 0; sym < pLen; sym++) {
232-
const trit = majorityVote(baseStart + offset + sym * pollsPerSymbol, pollsPerSymbol);
233-
votedPattern.push(trit);
234-
if (trit !== SYNC_PREAMBLE[sym]) {
235-
match = false;
222+
const symStart = t0 + sym * SYMBOL_DURATION_S;
223+
const symEnd = symStart + SYMBOL_DURATION_S;
224+
225+
let matchCount = 0;
226+
let validCount = 0;
227+
const freq: Record<number, number> = {};
228+
229+
for (const p of syncPolls) {
230+
if (p.time >= symStart && p.time < symEnd && p.trit >= 0) {
231+
freq[p.trit] = (freq[p.trit] ?? 0) + 1;
232+
validCount++;
233+
if (p.trit === SYNC_PREAMBLE[sym]) matchCount++;
234+
}
236235
}
237-
}
238236

239-
console.log(`[RX][SYNC] Offset ${offset}: [${votedPattern.join(',')}]`);
237+
if (validCount === 0) {
238+
allMatched = false;
239+
break;
240+
}
241+
242+
const majorityTrit = Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
243+
244+
if (majorityTrit !== SYNC_PREAMBLE[sym]) {
245+
allMatched = false;
246+
break;
247+
}
248+
249+
// Score based on how clean the votes were
250+
score += (matchCount / validCount);
251+
}
240252

241-
if (match) return offset;
253+
if (allMatched && score > bestScore) {
254+
bestScore = score;
255+
bestT0 = t0;
256+
}
242257
}
243-
return -1;
258+
return bestT0;
244259
}
245260

246261
/** Majority-vote a symbol from the current vote bucket (used in COLLECTING). */
@@ -253,12 +268,67 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
253268
return Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
254269
}
255270

271+
function checkAndParsePackets() {
272+
const minTritsForMinPacket = Math.ceil(((PACKET_HEADER_BYTES + 1 + PACKET_CRC_BYTES) * 8) / 3);
273+
const maxTritsPerPacket = Math.ceil(((PACKET_HEADER_BYTES + MAX_PAYLOAD_BYTES + PACKET_CRC_BYTES) * 8) / 3);
274+
275+
if (allTrits.length >= minTritsForMinPacket) {
276+
let foundPacket = false;
277+
const scanLimit = Math.min(allTrits.length - minTritsForMinPacket + 1, maxTritsPerPacket);
278+
279+
for (let offset = 0; offset < scanLimit; offset++) {
280+
const slicedTrits = allTrits.slice(offset);
281+
const byteCount = Math.floor((slicedTrits.length * 3) / 8);
282+
if (byteCount < PACKET_HEADER_BYTES + 1 + PACKET_CRC_BYTES) break;
283+
284+
const raw = tritsToBytes(slicedTrits, byteCount);
285+
const packet = parsePacket(raw);
286+
287+
if (packet && packet.crcValid) {
288+
console.log(`[RX] ✅ Valid packet at offset ${offset}: chunk ${packet.chunkIndex + 1}/${packet.totalChunks}, ${packet.payload.length}B`);
289+
290+
if (!receivedPackets.has(packet.chunkIndex)) {
291+
totalExpectedChunks = packet.totalChunks;
292+
receivedPackets.set(packet.chunkIndex, packet.payload);
293+
onStatus({
294+
type: 'receiving',
295+
chunk: receivedPackets.size,
296+
totalChunks: totalExpectedChunks,
297+
});
298+
}
299+
300+
const tritsConsumed = offset + Math.ceil(
301+
((PACKET_HEADER_BYTES + packet.payload.length + PACKET_CRC_BYTES) * 8) / 3
302+
);
303+
allTrits.splice(0, tritsConsumed);
304+
foundPacket = true;
305+
306+
if (totalExpectedChunks > 0 && receivedPackets.size >= totalExpectedChunks) {
307+
phase = 'DONE';
308+
const reconstructed = reassemble(receivedPackets, totalExpectedChunks);
309+
const text = bytesToText(reconstructed);
310+
console.log(`[RX] ✅ Complete! Decoded: "${text}"`);
311+
onStatus({ type: 'complete', text });
312+
stop();
313+
}
314+
break;
315+
}
316+
}
317+
318+
if (!foundPacket && allTrits.length > maxTritsPerPacket * 2) {
319+
console.log(`[RX] Buffer overflow (${allTrits.length} trits), dropping 1.`);
320+
allTrits.shift();
321+
}
322+
}
323+
}
324+
256325
onStatus({ type: 'listening' });
257326

258327
// --- Main polling loop ---
259328
pollTimer = setInterval(() => {
260-
if (stopped) return;
329+
if (stopped || !ctx) return;
261330
analyser.getByteFrequencyData(fftData);
331+
const now = ctx.currentTime;
262332

263333
if (phase === 'WAITING_A') {
264334
if (detectHandshakeTone(fftData, sampleRate, HANDSHAKE_FREQ_A)) {
@@ -282,10 +352,9 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
282352
if (detectHandshakeTone(fftData, sampleRate, HANDSHAKE_FREQ_B)) {
283353
phase = 'SYNCING';
284354
syncStartedAt = Date.now();
285-
rawPolls.length = 0;
286-
voteBucket.length = 0;
355+
syncPolls.length = 0;
287356
allTrits.length = 0;
288-
console.log('[RX] Handshake B detected. Scanning for sync preamble (multi-offset)...');
357+
console.log('[RX] Handshake B detected. Scanning for sync preamble (time-domain)...');
289358
onStatus({ type: 'receiving', chunk: 0, totalChunks: 0 });
290359
} else if (elapsed > windowMs) {
291360
console.log(`[RX] Handshake B timeout after ${elapsed}ms. Resetting.`);
@@ -294,115 +363,58 @@ export async function startReceiver(onStatus: RxStatusCallback): Promise<() => v
294363
}
295364

296365
} else if (phase === 'SYNCING') {
297-
// Store every individual poll result in the raw buffer.
298366
const trit = detectFSKSymbol(fftData, sampleRate);
299-
rawPolls.push(trit);
300-
301-
// Every few polls, try to find the preamble at any offset.
302-
if (rawPolls.length > 0 && rawPolls.length % pollsPerSymbol === 0) {
303-
// Debug: log the rolling raw buffer
304-
const recentRaw = rawPolls.slice(-pollsPerSymbol * 4).map(t => t >= 0 ? t : '.').join('');
305-
console.log(`[RX][SYNC] Buffer (${rawPolls.length} polls). Recent raw: [${recentRaw}]`);
306-
307-
const offset = findPreambleOffset();
308-
if (offset >= 0) {
309-
// Preamble found at this phase offset!
367+
syncPolls.push({ time: now, trit });
368+
369+
// Try to map the preamble onto the acoustic timeline every few polls
370+
if (syncPolls.length > 0 && syncPolls.length % 5 === 0) {
371+
const t0 = findPreambleStartTime();
372+
if (t0 >= 0) {
373+
console.log(`[RX] ✅ Preamble aligned! Start time t0=${t0.toFixed(3)}. Transitioning to COLLECTING.`);
374+
dataStartTime = t0 + (SYNC_PREAMBLE.length * SYMBOL_DURATION_S);
310375
phase = 'COLLECTING';
376+
currentSymbolIndex = 0;
311377
voteBucket.length = 0;
312-
allTrits.length = 0;
313-
314-
// Pre-fill the vote bucket with any remaining raw polls
315-
// after the preamble ends, aligned to the discovered offset.
316-
// Any polls that arrived AFTER the preamble in the raw buffer
317-
// are already the start of data — but since we check every
318-
// pollsPerSymbol polls, there are typically 0 leftover.
319-
320-
console.log(`[RX] ✅ Preamble found! Phase offset=${offset}, rawPolls=${rawPolls.length}. Data collection aligned.`);
321-
rawPolls.length = 0; // Free memory
378+
syncPolls.length = 0; // free memory
322379
}
323380
}
324381

325-
// Timeout: give up and reset.
326382
if (Date.now() - syncStartedAt > SYNC_TIMEOUT_MS) {
327383
console.log('[RX] Sync preamble timeout. Resetting.');
328384
phase = 'WAITING_A';
329-
rawPolls.length = 0;
385+
syncPolls.length = 0;
330386
allTrits.length = 0;
331387
onStatus({ type: 'listening' });
332388
}
333389

334390
} else if (phase === 'COLLECTING') {
335391
const trit = detectFSKSymbol(fftData, sampleRate);
336-
voteBucket.push(trit);
337392

338-
if (voteBucket.length >= pollsPerSymbol) {
339-
const winner = commitSymbol();
340-
if (winner >= 0) {
341-
allTrits.push(winner);
342-
console.log(`[RX] Symbol: trit=${winner}, total=${allTrits.length}`);
343-
}
393+
// Ignore late reverberations from the preamble before data starts
394+
if (now < dataStartTime) return;
344395

345-
// Minimum trits for smallest possible packet.
346-
const minTritsForMinPacket = Math.ceil(
347-
((PACKET_HEADER_BYTES + 1 + PACKET_CRC_BYTES) * 8) / 3
348-
);
349-
const maxTritsPerPacket = Math.ceil(
350-
((PACKET_HEADER_BYTES + MAX_PAYLOAD_BYTES + PACKET_CRC_BYTES) * 8) / 3
351-
);
352-
353-
if (allTrits.length >= minTritsForMinPacket) {
354-
// Sliding-window scan for a valid packet.
355-
let foundPacket = false;
356-
const scanLimit = Math.min(
357-
allTrits.length - minTritsForMinPacket + 1,
358-
maxTritsPerPacket
359-
);
396+
// Determine exactly which symbol this poll belongs to mathematically
397+
const symIndex = Math.floor((now - dataStartTime) / SYMBOL_DURATION_S);
360398

361-
for (let offset = 0; offset < scanLimit; offset++) {
362-
const slicedTrits = allTrits.slice(offset);
363-
const byteCount = Math.floor((slicedTrits.length * 3) / 8);
364-
if (byteCount < PACKET_HEADER_BYTES + 1 + PACKET_CRC_BYTES) break;
365-
366-
const raw = tritsToBytes(slicedTrits, byteCount);
367-
const packet = parsePacket(raw);
368-
369-
if (packet && packet.crcValid) {
370-
console.log(`[RX] ✅ Valid packet at offset ${offset}: chunk ${packet.chunkIndex + 1}/${packet.totalChunks}, ${packet.payload.length}B`);
371-
372-
if (!receivedPackets.has(packet.chunkIndex)) {
373-
totalExpectedChunks = packet.totalChunks;
374-
receivedPackets.set(packet.chunkIndex, packet.payload);
375-
onStatus({
376-
type: 'receiving',
377-
chunk: receivedPackets.size,
378-
totalChunks: totalExpectedChunks,
379-
});
380-
}
381-
382-
const tritsConsumed = offset + Math.ceil(
383-
((PACKET_HEADER_BYTES + packet.payload.length + PACKET_CRC_BYTES) * 8) / 3
384-
);
385-
allTrits.splice(0, tritsConsumed);
386-
foundPacket = true;
387-
388-
if (totalExpectedChunks > 0 && receivedPackets.size >= totalExpectedChunks) {
389-
phase = 'DONE';
390-
const reconstructed = reassemble(receivedPackets, totalExpectedChunks);
391-
const text = bytesToText(reconstructed);
392-
console.log(`[RX] ✅ Complete! Decoded: "${text}"`);
393-
onStatus({ type: 'complete', text });
394-
stop();
395-
}
396-
break;
397-
}
398-
}
399+
// Have we crossed a symbol boundary? (Or multiple, if JS lagged!)
400+
if (symIndex > currentSymbolIndex) {
401+
// Catch up to current time, committing any pending symbols
402+
while (currentSymbolIndex < symIndex) {
403+
const winner = commitSymbol();
404+
allTrits.push(winner);
405+
console.log(`[RX] Symbol ${currentSymbolIndex}: trit=${winner} (Total=${allTrits.length})`);
406+
currentSymbolIndex++;
399407

400-
if (!foundPacket && allTrits.length > maxTritsPerPacket * 2) {
401-
console.log(`[RX] Buffer overflow (${allTrits.length} trits), dropping 1.`);
402-
allTrits.shift();
403-
}
408+
// Parse immediately to detect End Of Packet
409+
checkAndParsePackets();
410+
if (stopped) return; // Stop if checkAndParsePackets() called stop() and finished phase
404411
}
405412
}
413+
414+
// Only push valid signal into the vote bucket
415+
if (trit >= 0) {
416+
voteBucket.push(trit);
417+
}
406418
}
407419
}, RX_POLL_INTERVAL_MS);
408420

src/services/protocol.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const HANDSHAKE_SILENCE_S = 0.25;
5959
// its symbol vote windows with the transmitter's symbol boundaries.
6060
// This is the standard technique used in real FSK protocols (UART start bits,
6161
// modem training sequences, etc.).
62-
export const SYNC_PREAMBLE: readonly number[] = [7, 0, 7, 0, 7, 0, 7, 0];
62+
export const SYNC_PREAMBLE: readonly number[] = [1, 5, 1, 5, 1, 5, 1, 5];
6363

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

0 commit comments

Comments
 (0)