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