@@ -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