import { ToneSequenceEvent, ToneEntry, ToneState, EmitPoint, ToneDetectionConfig } from '../types/ToneTypes'; export interface Config { earlyEmitDelay: number; resetTickInterval: number; length: number; } export const ConfigDefault: Config = { earlyEmitDelay: 0.40, resetTickInterval: 0.32, length: 8 }; /** * Sequence State Machine for React Native * Based on SequenceStateMachine.swift from version 19 */ export class SequenceStateMachine { private state: ToneState = ToneState.AWAITING_FIRST; private keyIndex: number = -1; private actualToneCount: number = 0; // Track only actual tones (excluding bridge tones) private seq: ToneEntry[] = []; private seqSnapshot: ToneEntry[] = []; private is4thKey: boolean = false; private is6thKey: boolean = false; private is8thKey: boolean = false; private earlyTimer: NodeJS.Timeout | null = null; private idleResetTimer: NodeJS.Timeout | null = null; private shouldClearQueue: boolean = true; public onPartial: ((seq: ToneEntry[]) => void) | null = null; public onSequence: ((sequence: string, point: EmitPoint) => void) | null = null; private cfg: Config; constructor( config: Config = ConfigDefault, private resolveTag: (low: number, high: number) => string | null ) { this.cfg = config; this.seq = Array.from({ length: this.cfg.length }, () => ({ tone: '', bridge: 0, wide: 0 })); this.startIdleResetTimer(); } public destroy(): void { if (this.earlyTimer) { clearTimeout(this.earlyTimer); this.earlyTimer = null; } if (this.idleResetTimer) { clearInterval(this.idleResetTimer); this.idleResetTimer = null; } } public feed(max1: { f: number; p: number } | null, max2: { f: number; p: number } | null, bridge: { bridge?: number; wide?: number } | null): void { // Debug: Log state machine input (matching Swift exactly) if (max1 && max2) { console.log(`🎯 StateMachine: Received peaks - max1: ${max1.f}Hz @ ${max1.p.toFixed(6)}, max2: ${max2.f}Hz @ ${max2.p.toFixed(6)}`); } if (bridge) { console.log(`🎯 StateMachine: Received bridge - ${bridge.bridge}Hz, wide: ${bridge.wide}`); } let dualTag: string | null = null; if (max1 && max2) { // Try both possible orderings since we don't know which is low/high // First try: a as low, b as high console.log(`🎯 StateMachine: Trying a as low, b as high - low: ${max1.f}Hz, high: ${max2.f}Hz`); dualTag = this.resolveTag(max1.f, max2.f); if (dualTag) { console.log(`🎯 StateMachine: Dual tone detected - ${dualTag} (low: ${max1.f}Hz, high: ${max2.f}Hz)`); } else { // Try reverse: b as low, a as high console.log(`🎯 StateMachine: Trying b as low, a as high - low: ${max2.f}Hz, high: ${max1.f}Hz`); dualTag = this.resolveTag(max2.f, max1.f); if (dualTag) { console.log(`🎯 StateMachine: Dual tone detected - ${dualTag} (low: ${max2.f}Hz, high: ${max1.f}Hz)`); } else { console.log(`🎯 StateMachine: No dual tone found for either ordering: (${max1.f}Hz, ${max2.f}Hz) or (${max2.f}Hz, ${max1.f}Hz)`); } } } const isDual = (dualTag !== null); const isBridgeHit = (bridge !== null); // Determine if a tone should be added and what kind let toneToAdd: { tag: string; bridge: { bridge?: number; wide?: number } | null } | null = null; if (isDual) { toneToAdd = { tag: dualTag!, bridge: bridge }; } else if (isBridgeHit) { // Assign a placeholder tag for bridge tones to ensure they contribute to sequence length toneToAdd = { tag: `BRDG_W${bridge!.wide}`, bridge: bridge }; } const addTone = (tag: string, bridge: { bridge?: number; wide?: number } | null): void => { if (this.keyIndex < 0) this.keyIndex = 0; if (this.keyIndex >= this.cfg.length) return; const bridgeValue = bridge?.bridge || 0; const wideValue = bridge?.wide || 0; // Check if this is an actual tone (not a bridge tone) const isActualTone = !tag.startsWith('BRDG_W'); if (isActualTone) { this.actualToneCount += 1; } this.seq[this.keyIndex] = { tone: tag, bridge: bridgeValue, wide: wideValue }; this.keyIndex += 1; this.shouldClearQueue = false; console.log(`🎯 StateMachine: Added tone to sequence - '${tag}' at index ${this.keyIndex-1}, bridge: ${bridgeValue}, wide: ${wideValue}, actualToneCount: ${this.actualToneCount}`); this.onPartial?.(this.seq); }; if (toneToAdd) { // Add the tone first (this increments both keyIndex and actualToneCount if needed) addTone(toneToAdd.tag, toneToAdd.bridge); // Check if we have a terminating bridge tone before proceeding const has4WideBridge = this.exists4WideBridge(); const has6WideBridge = this.exists6WideBridge(); const has8WideBridge = this.seq.some(entry => entry.wide === 8); // Check for immediate emission when terminating bridge tones are present if (has4WideBridge && this.actualToneCount === 4) { console.log(`🎯 StateMachine: Found 4-wide bridge tone at 4th actual tone - emitting immediately and terminating sequence`); this.is4thKey = true; this.is6thKey = false; this.is8thKey = false; this.seqSnapshot = this.seq; this.evaluateAndEmit(this.seqSnapshot, EmitPoint.FOURTH); this.state = ToneState.COMPLETED; return; } else if (has6WideBridge && this.actualToneCount === 6) { console.log(`🎯 StateMachine: Found 6-wide bridge tone at 6th actual tone - emitting immediately and terminating sequence`); this.is6thKey = true; this.is4thKey = false; this.is8thKey = false; this.seqSnapshot = this.seq; this.evaluateAndEmit(this.seqSnapshot, EmitPoint.SIXTH); this.state = ToneState.COMPLETED; return; } else if (has8WideBridge && this.actualToneCount === 8) { console.log(`🎯 StateMachine: Found 8-wide bridge tone at 8th actual tone - emitting immediately and terminating sequence`); this.is4thKey = false; this.is6thKey = false; this.is8thKey = true; this.seqSnapshot = this.seq; this.evaluateAndEmit(this.seqSnapshot, EmitPoint.EIGHTH); this.state = ToneState.COMPLETED; return; } // Skip state transitions if we have terminating bridge tones but haven't reached the emission point yet if ((has4WideBridge && this.actualToneCount < 4) || (has6WideBridge && this.actualToneCount < 6) || (has8WideBridge && this.actualToneCount < 8)) { console.log(`🎯 StateMachine: Skipping state transitions - terminating bridge tone present (4W: ${has4WideBridge}, 6W: ${has6WideBridge}, 8W: ${has8WideBridge}), actualToneCount: ${this.actualToneCount}`); return; } // Transition state based on ACTUAL tone count (not sequence position) // This ensures state transitions align with emission triggers console.log(`🎯 StateMachine: Actual tone count: ${this.actualToneCount}, Current state: ${this.state}`); if (this.actualToneCount === 1 && this.state === ToneState.AWAITING_FIRST) { console.log(`🎯 StateMachine: Transitioning from awaitingFirst to awaitingSecond (actualToneCount: ${this.actualToneCount})`); this.state = ToneState.AWAITING_SECOND; } else if (this.actualToneCount === 2 && this.state === ToneState.AWAITING_SECOND) { console.log(`🎯 StateMachine: Transitioning from awaitingSecond to awaitingThird (actualToneCount: ${this.actualToneCount})`); this.state = ToneState.AWAITING_THIRD; } else if (this.actualToneCount === 3 && this.state === ToneState.AWAITING_THIRD) { console.log(`🎯 StateMachine: Transitioning from awaitingThird to awaitingFourth (actualToneCount: ${this.actualToneCount})`); this.state = ToneState.AWAITING_FOURTH; } else if (this.actualToneCount === 4 && this.state === ToneState.AWAITING_FOURTH) { console.log(`🎯 StateMachine: Triggering FOURTH emission (actualToneCount: ${this.actualToneCount})`); this.is4thKey = true; this.is6thKey = false; this.is8thKey = false; this.seqSnapshot = this.seq; this.scheduleTimerEmit(EmitPoint.FOURTH); this.state = ToneState.AWAITING_FIFTH; } else if (this.actualToneCount === 5 && this.state === ToneState.AWAITING_FIFTH) { console.log(`🎯 StateMachine: Transitioning from awaitingFifth to awaitingSixth (actualToneCount: ${this.actualToneCount})`); this.state = ToneState.AWAITING_SIXTH; } else if (this.actualToneCount === 6 && this.state === ToneState.AWAITING_SIXTH) { console.log(`🎯 StateMachine: Triggering SIXTH emission (actualToneCount: ${this.actualToneCount})`); this.is6thKey = true; this.is4thKey = false; this.is8thKey = false; this.seqSnapshot = this.seq; this.scheduleTimerEmit(EmitPoint.SIXTH); this.state = ToneState.AWAITING_SEVENTH; } else if (this.actualToneCount === 7 && this.state === ToneState.AWAITING_SEVENTH) { console.log(`🎯 StateMachine: Transitioning from awaitingSeventh to awaitingEighth (actualToneCount: ${this.actualToneCount})`); this.state = ToneState.AWAITING_EIGHTH; } else if (this.actualToneCount === 8 && this.state === ToneState.AWAITING_EIGHTH) { console.log(`🎯 StateMachine: Triggering EIGHTH emission (actualToneCount: ${this.actualToneCount})`); this.is4thKey = false; this.is6thKey = false; this.is8thKey = true; this.seqSnapshot = this.seq; this.evaluateAndEmit(this.seqSnapshot, EmitPoint.EIGHTH); this.state = ToneState.COMPLETED; } else if (this.state === ToneState.COMPLETED) { console.log(`🎯 StateMachine: Not adding tones - sequence already completed`); } else { console.log(`🎯 StateMachine: No state transition - actualToneCount: ${this.actualToneCount}, state: ${this.state}`); } } } private evaluateAndEmit(snapshot: ToneEntry[], point: EmitPoint): void { // Filter out bridge tones from the sequence - only include actual tone tags // Bridge tones like "BRDG_W8" are used for internal validation but shouldn't be sent to API const sequence = snapshot .filter(entry => !entry.tone.startsWith('BRDG_W')) .map(entry => entry.tone) .join(''); console.log(`🎯 StateMachine: Evaluating sequence at ${point} - sequence: '${sequence}', length: ${sequence.length}`); console.log(`🎯 StateMachine: Snapshot tones: ${snapshot.map(entry => `${entry.tone}(bridge:${entry.bridge},wide:${entry.wide})`).join(', ')}`); // Use wide values from full snapshot (including bridge tones) like Swift version const wides = snapshot.map(entry => entry.wide).filter(w => w !== 0); console.log(`🎯 StateMachine: Wide values: ${wides}`); // Use Swift-style dictionary grouping const buckets = wides.reduce((acc, wide) => { acc[wide] = (acc[wide] || 0) + 1; return acc; }, {} as { [key: number]: number }); console.log(`🎯 StateMachine: Wide buckets: ${JSON.stringify(buckets)}`); // Find max count and corresponding wide value - more aligned with Swift approach const bucketEntries = Object.entries(buckets); const maxCount = bucketEntries.length > 0 ? Math.max(...Object.values(buckets)) : 0; const highestWideEntry = bucketEntries.find(([, count]) => count === maxCount); if (bucketEntries.length === 0 || !highestWideEntry) { console.log('🎯 StateMachine: No valid wide values found, skipping emission'); return; } const highestWideNum = parseInt(highestWideEntry[0]); console.log(`🎯 StateMachine: Max count: ${maxCount}, highest wide: ${highestWideNum}`); switch (point) { case EmitPoint.FOURTH: const allAreFour = wides.every(w => w !== 6 && w !== 8); console.log(`🎯 StateMachine: Fourth evaluation - allAreFour: ${allAreFour}, condition1: ${highestWideNum === 4 && maxCount >= 3}, condition2: ${highestWideNum === 4 && allAreFour}`); if ((highestWideNum === 4 && maxCount >= 3) || (highestWideNum === 4 && allAreFour)) { console.log(`🎯 StateMachine: ✅ EMITTING FOURTH SEQUENCE: '${sequence}'`); this.onSequence?.(sequence, EmitPoint.FOURTH); } else { console.log(`🎯 StateMachine: ❌ Fourth sequence conditions not met`); } break; case EmitPoint.SIXTH: console.log(`🎯 StateMachine: Sixth evaluation - highestWide == 6: ${highestWideNum === 6}`); if (highestWideNum === 6) { console.log(`🎯 StateMachine: ✅ EMITTING SIXTH SEQUENCE: '${sequence}'`); this.onSequence?.(sequence, EmitPoint.SIXTH); } else { console.log(`🎯 StateMachine: ❌ Sixth sequence conditions not met`); } break; case EmitPoint.EIGHTH: console.log(`🎯 StateMachine: Eighth evaluation - highestWide == 8: ${highestWideNum === 8}`); if (highestWideNum === 8) { console.log(`🎯 StateMachine: ✅ EMITTING EIGHTH SEQUENCE: '${sequence}'`); this.onSequence?.(sequence, EmitPoint.EIGHTH); } else { console.log(`🎯 StateMachine: ❌ Eighth sequence conditions not met`); } break; } } private scheduleEarlyEmit(point: EmitPoint): void { if (this.earlyTimer) { clearTimeout(this.earlyTimer); } this.earlyTimer = setTimeout(() => { switch (point) { case EmitPoint.FOURTH: if (this.is4thKey) { this.evaluateAndEmit(this.seqSnapshot, EmitPoint.FOURTH); } this.is4thKey = false; break; case EmitPoint.SIXTH: if (this.is6thKey) { this.evaluateAndEmit(this.seqSnapshot, EmitPoint.SIXTH); } this.is6thKey = false; break; case EmitPoint.EIGHTH: // No action needed for eighth break; } this.shouldClearQueue = true; }, this.cfg.earlyEmitDelay * 1000); // Use config value like Swift } private scheduleTimerEmit(point: EmitPoint): void { if (this.earlyTimer) { clearTimeout(this.earlyTimer); } this.earlyTimer = setTimeout(() => { switch (point) { case EmitPoint.FOURTH: if (this.is4thKey) { this.evaluateAndEmit(this.seqSnapshot, EmitPoint.FOURTH); } this.is4thKey = false; break; case EmitPoint.SIXTH: if (this.is6thKey) { this.evaluateAndEmit(this.seqSnapshot, EmitPoint.SIXTH); } this.is6thKey = false; break; case EmitPoint.EIGHTH: // No action needed for eighth break; } this.shouldClearQueue = true; }, 0.4 * 1000); // Use 0.4 seconds like Swift } private startIdleResetTimer(): void { this.idleResetTimer = setInterval(() => { if (this.shouldClearQueue && this.keyIndex >= 0) { this.reset(); } else { this.shouldClearQueue = true; } }, this.cfg.resetTickInterval * 1000); } public reset(): void { if (this.earlyTimer) { clearTimeout(this.earlyTimer); this.earlyTimer = null; } this.is4thKey = false; this.is6thKey = false; this.is8thKey = false; this.keyIndex = -1; this.actualToneCount = 0; this.state = ToneState.AWAITING_FIRST; this.seq = Array.from({ length: this.cfg.length }, () => ({ tone: '', bridge: 0, wide: 0 })); console.log('🎯 StateMachine: Reset'); } private exists4WideBridge(): boolean { return this.seq.some(entry => entry.wide === 4); } private exists6WideBridge(): boolean { return this.seq.some(entry => entry.wide === 6); } }