@@ -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
0 commit comments