acoustic-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. acoustic_engine/__init__.py +54 -0
  2. acoustic_engine/analysis/__init__.py +0 -0
  3. acoustic_engine/analysis/event_buffer.py +85 -0
  4. acoustic_engine/analysis/generator.py +262 -0
  5. acoustic_engine/analysis/matcher.py +228 -0
  6. acoustic_engine/analysis/windowed_matcher.py +358 -0
  7. acoustic_engine/config.py +453 -0
  8. acoustic_engine/engine.py +294 -0
  9. acoustic_engine/events.py +50 -0
  10. acoustic_engine/input/__init__.py +0 -0
  11. acoustic_engine/input/listener.py +205 -0
  12. acoustic_engine/models.py +103 -0
  13. acoustic_engine/parallel_engine.py +169 -0
  14. acoustic_engine/processing/__init__.py +0 -0
  15. acoustic_engine/processing/dsp.py +166 -0
  16. acoustic_engine/processing/filter.py +101 -0
  17. acoustic_engine/profiles.py +221 -0
  18. acoustic_engine/runner.py +138 -0
  19. acoustic_engine/tester/__init__.py +206 -0
  20. acoustic_engine/tester/__main__.py +9 -0
  21. acoustic_engine/tester/display.py +198 -0
  22. acoustic_engine/tester/mixer.py +166 -0
  23. acoustic_engine/tester/runner.py +384 -0
  24. acoustic_engine/tuner/__init__.py +61 -0
  25. acoustic_engine/tuner/__main__.py +9 -0
  26. acoustic_engine/tuner/app.js +1617 -0
  27. acoustic_engine/tuner/audio-engine.js +1222 -0
  28. acoustic_engine/tuner/check_braces.py +24 -0
  29. acoustic_engine/tuner/index.html +392 -0
  30. acoustic_engine/tuner/styles.css +1751 -0
  31. acoustic_engine/tuner/visualizer.js +861 -0
  32. acoustic_engine-1.0.0.dist-info/METADATA +574 -0
  33. acoustic_engine-1.0.0.dist-info/RECORD +37 -0
  34. acoustic_engine-1.0.0.dist-info/WHEEL +5 -0
  35. acoustic_engine-1.0.0.dist-info/entry_points.txt +2 -0
  36. acoustic_engine-1.0.0.dist-info/licenses/LICENSE +411 -0
  37. acoustic_engine-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,54 @@
1
+ """Acoustic Alarm Engine - Real-time audio pattern detection.
2
+
3
+ A standalone library for detecting acoustic alarm patterns including
4
+ smoke alarms, CO detectors, appliance beeps, and other repetitive sounds.
5
+
6
+ Usage:
7
+ from acoustic_engine import Engine, AudioConfig
8
+ from acoustic_engine.profiles import load_profiles_from_yaml
9
+
10
+ profiles = load_profiles_from_yaml("smoke_alarm.yaml")
11
+ engine = Engine(profiles, AudioConfig(), on_detection=print)
12
+ engine.start()
13
+ """
14
+
15
+ __version__ = "1.0.0"
16
+ __author__ = "Your Name"
17
+
18
+ # Core exports
19
+ from .analysis.event_buffer import EventBuffer
20
+ from .analysis.windowed_matcher import WindowedMatcher
21
+ from .config import EngineConfig, compute_finest_resolution
22
+ from .engine import Engine
23
+ from .input.listener import AudioConfig, AudioListener
24
+ from .models import AlarmProfile, Range, ResolutionConfig, Segment
25
+ from .processing.filter import FrequencyFilter
26
+ from .profiles import (
27
+ load_profile_from_yaml,
28
+ load_profiles_from_yaml,
29
+ save_profile_to_yaml,
30
+ )
31
+
32
+ __all__ = [
33
+ # Version
34
+ "__version__",
35
+ # Core classes
36
+ "Engine",
37
+ "AudioConfig",
38
+ "AudioListener",
39
+ "FrequencyFilter",
40
+ "EventBuffer",
41
+ "WindowedMatcher",
42
+ # Configuration
43
+ "EngineConfig",
44
+ "ResolutionConfig",
45
+ "compute_finest_resolution",
46
+ # Models
47
+ "AlarmProfile",
48
+ "Segment",
49
+ "Range",
50
+ # Profile loading
51
+ "load_profile_from_yaml",
52
+ "load_profiles_from_yaml",
53
+ "save_profile_to_yaml",
54
+ ]
File without changes
@@ -0,0 +1,85 @@
1
+ """Circular buffer for storing recent audio events for windowed analysis."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+
6
+ from ..events import ToneEvent
7
+
8
+
9
+ @dataclass
10
+ class EventBuffer:
11
+ """Stores recent audio events for windowed pattern matching.
12
+
13
+ Maintains a time-ordered list of events, automatically pruning
14
+ events older than max_duration.
15
+ """
16
+
17
+ max_duration: float = 30.0 # Keep events from last 30s
18
+ _events: List[ToneEvent] = field(default_factory=list)
19
+
20
+ def add(self, event: ToneEvent) -> None:
21
+ """Add an event to the buffer.
22
+
23
+ Events are assumed to arrive roughly in order. The buffer
24
+ automatically prunes old events.
25
+
26
+ Args:
27
+ event: ToneEvent to add
28
+ """
29
+ self._events.append(event)
30
+
31
+ # Prune old events (keep those within max_duration of latest)
32
+ if self._events:
33
+ latest_time = max(e.timestamp for e in self._events)
34
+ cutoff = latest_time - self.max_duration
35
+ self._events = [e for e in self._events if e.timestamp >= cutoff]
36
+
37
+ def get_window(self, end_time: float, window_duration: float) -> List[ToneEvent]:
38
+ """Get all events within a time window.
39
+
40
+ Args:
41
+ end_time: End of the window (usually current time)
42
+ window_duration: How far back to look
43
+
44
+ Returns:
45
+ List of events within [end_time - window_duration, end_time]
46
+ """
47
+ start_time = end_time - window_duration
48
+ return [e for e in self._events if start_time <= e.timestamp <= end_time]
49
+
50
+ def get_events_in_range(
51
+ self,
52
+ start_time: float,
53
+ end_time: float,
54
+ freq_min: Optional[float] = None,
55
+ freq_max: Optional[float] = None,
56
+ ) -> List[ToneEvent]:
57
+ """Get events in a time range, optionally filtered by frequency.
58
+
59
+ Args:
60
+ start_time: Start of range
61
+ end_time: End of range
62
+ freq_min: Optional minimum frequency filter
63
+ freq_max: Optional maximum frequency filter
64
+
65
+ Returns:
66
+ Filtered list of events
67
+ """
68
+ events = [e for e in self._events if start_time <= e.timestamp <= end_time]
69
+
70
+ if freq_min is not None and freq_max is not None:
71
+ events = [e for e in events if freq_min <= e.frequency <= freq_max]
72
+
73
+ return sorted(events, key=lambda e: e.timestamp)
74
+
75
+ def clear(self) -> None:
76
+ """Clear all events from the buffer."""
77
+ self._events.clear()
78
+
79
+ @property
80
+ def events(self) -> List[ToneEvent]:
81
+ """Get all events in the buffer (read-only)."""
82
+ return list(self._events)
83
+
84
+ def __len__(self) -> int:
85
+ return len(self._events)
@@ -0,0 +1,262 @@
1
+ """Generates discrete events from continuous DSP data."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import List
6
+
7
+ from ..events import AudioEvent, ToneEvent
8
+ from ..processing.dsp import Peak
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class ActiveTone:
15
+ """Tracks a currently playing tone."""
16
+
17
+ start_time: float
18
+ frequency: float
19
+ max_magnitude: float
20
+ last_seen_time: float
21
+ last_strong_time: float # Last time the signal was above 50% of max
22
+ last_magnitude: float # Last chunk's magnitude for dip detection
23
+ samples_count: int
24
+
25
+
26
+ class EventGenerator:
27
+ """Converts continuous spectral peaks into discrete Tone/Silence events.
28
+
29
+ This class acts as the bridge between the DSP layer (frequency peaks) and
30
+ the pattern matchers (audio events). It handles:
31
+ - **Debouncing**: Ignoring short transient noises.
32
+ - **Continuity**: Stitching together spectral peaks across multiple
33
+ chunks into coherent `ToneEvent`s.
34
+ - **Dropout Tolerance**: Allowing short gaps in a signal (due to noise
35
+ or interference) without breaking the tone event.
36
+ - **Temporal Sorting**: Ensuring events are emitted in strict chronological order.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ sample_rate: int,
42
+ chunk_size: int,
43
+ min_tone_duration: float = 0.1,
44
+ dropout_tolerance: float = 0.15,
45
+ frequency_tolerance: float = 50.0,
46
+ freq_smoothing: float = 0.3,
47
+ dip_threshold: float = 0.6,
48
+ strong_signal_ratio: float = 0.5,
49
+ coalesce_ratio: float = 0.5,
50
+ ):
51
+ """Initialize the event generator.
52
+
53
+ Args:
54
+ sample_rate: Audio sample rate in Hz.
55
+ chunk_size: Number of samples per chunk.
56
+ min_tone_duration: Minimum duration for a detected tone to be valid.
57
+ dropout_tolerance: Maximum silence gap allowed within a single tone.
58
+ frequency_tolerance: Hz range to consider peaks as the same tone.
59
+ freq_smoothing: Alpha for EMA frequency tracking.
60
+ dip_threshold: Ratio for instantaneous dip detection.
61
+ strong_signal_ratio: Ratio to consider signal "strong" for duration.
62
+ coalesce_ratio: Overlap ratio for merging concurrent events.
63
+ """
64
+ self.sample_rate = sample_rate
65
+ self.chunk_size = chunk_size
66
+ self.chunk_duration = chunk_size / sample_rate
67
+
68
+ # Safeguard: Ensure dropout tolerance is at least 1.5x chunk duration
69
+ # to prevent single-chunk noise from breaking tones.
70
+ safe_dropout = self.chunk_duration * 1.5
71
+ if dropout_tolerance < safe_dropout:
72
+ logger.warning(
73
+ f"dropout_tolerance ({dropout_tolerance:.3f}s) is too low for "
74
+ f"chunk_duration ({self.chunk_duration:.3f}s). "
75
+ f"Increasing to {safe_dropout:.3f}s for stability."
76
+ )
77
+ dropout_tolerance = safe_dropout
78
+
79
+ # Configuration
80
+ self.min_tone_duration = min_tone_duration
81
+ self.dropout_tolerance = dropout_tolerance
82
+ self.frequency_tolerance = frequency_tolerance
83
+ self.freq_smoothing = freq_smoothing
84
+ self.dip_threshold = dip_threshold
85
+ self.strong_signal_ratio = strong_signal_ratio
86
+ self.coalesce_ratio = coalesce_ratio
87
+
88
+ # State
89
+ self.active_tones: List[ActiveTone] = []
90
+ self.last_process_time = 0.0
91
+
92
+ # Buffer for events to ensure chronological output
93
+ self.pending_output: List[ToneEvent] = []
94
+
95
+ def process(self, peaks: List[Peak], timestamp: float) -> List[AudioEvent]:
96
+ """Process spectral peaks for a time slice and return completed events.
97
+
98
+ This function should be called for every analyzed audio chunk.
99
+
100
+ Args:
101
+ peaks: List of significant spectral peaks detected by the DSP layer.
102
+ timestamp: The end time of the current audio chunk in seconds.
103
+
104
+ Returns:
105
+ A list of completed `ToneEvent` objects. Note that events are only
106
+ emitted *after* they have finished (and the dropout tolerance timer
107
+ has expired), so there is inherent latency equal to `dropout_tolerance`.
108
+ """
109
+ # 1. Update active tones
110
+ current_active_indices = set()
111
+
112
+ for peak in peaks:
113
+ matched = False
114
+ for i, tone in enumerate(self.active_tones):
115
+ if abs(peak.frequency - tone.frequency) < self.frequency_tolerance:
116
+ # 2. Track frequency history/smoothing
117
+ tone.frequency = (
118
+ 1.0 - self.freq_smoothing
119
+ ) * tone.frequency + self.freq_smoothing * peak.frequency
120
+
121
+ # Upgrade: Instantaneous Dip Detection (Grandmaster feature)
122
+ # If magnitude drops by >40% compared to PREVIOUS chunk,
123
+ # we are likely entering the reverb tail.
124
+ magnitude_ratio = peak.magnitude / (
125
+ tone.last_magnitude if tone.last_magnitude > 0 else 1.0
126
+ )
127
+
128
+ if magnitude_ratio < self.dip_threshold:
129
+ # Significant dip - This is likely the end of the beep and start of reverb.
130
+ # We force a disconnect here so the beep duration isn't stretched.
131
+ matched = False
132
+ break
133
+
134
+ if peak.magnitude > tone.max_magnitude * self.strong_signal_ratio:
135
+ # Signal is still strong and consistent
136
+ tone.last_strong_time = timestamp
137
+
138
+ if peak.magnitude > tone.max_magnitude:
139
+ tone.max_magnitude = peak.magnitude
140
+
141
+ tone.last_magnitude = peak.magnitude # Track for NEXT dip check
142
+ tone.last_seen_time = timestamp
143
+ tone.samples_count += 1
144
+ current_active_indices.add(i)
145
+ matched = True
146
+ break
147
+
148
+ if not matched:
149
+ # New potential tone
150
+ new_tone = ActiveTone(
151
+ start_time=timestamp,
152
+ frequency=peak.frequency,
153
+ max_magnitude=peak.magnitude,
154
+ last_seen_time=timestamp,
155
+ last_strong_time=timestamp,
156
+ last_magnitude=peak.magnitude,
157
+ samples_count=1,
158
+ )
159
+ self.active_tones.append(new_tone)
160
+ current_active_indices.add(len(self.active_tones) - 1)
161
+
162
+ # 2. Check for ended tones
163
+ active_tones_next: List[ActiveTone] = []
164
+ new_events: List[ToneEvent] = []
165
+
166
+ for i, tone in enumerate(self.active_tones):
167
+ if i in current_active_indices:
168
+ active_tones_next.append(tone)
169
+ else:
170
+ time_since_seen = timestamp - tone.last_seen_time
171
+
172
+ if time_since_seen > self.dropout_tolerance:
173
+ # Tone ended - Use 'last_strong_time' for precision duration (Elite feature)
174
+ # This cuts off the reverb tail and restores the true pattern rhythm.
175
+ duration = (tone.last_strong_time - tone.start_time) + self.chunk_duration
176
+
177
+ # Safety check: ensure duration is at least one chunk
178
+ duration = max(self.chunk_duration, duration)
179
+
180
+ if duration >= self.min_tone_duration:
181
+ event = ToneEvent(
182
+ timestamp=tone.start_time,
183
+ duration=duration,
184
+ frequency=tone.frequency,
185
+ magnitude=tone.max_magnitude,
186
+ confidence=1.0,
187
+ )
188
+ new_events.append(event)
189
+ logger.debug(
190
+ f"Generated Tone: {event.frequency:.0f}Hz, {event.duration:.2f}s"
191
+ )
192
+ else:
193
+ # Keep waiting (dropout tolerance)
194
+ active_tones_next.append(tone)
195
+
196
+ self.active_tones = active_tones_next
197
+ self.last_process_time = timestamp
198
+
199
+ # 3. Add new events to pending output buffer
200
+ if new_events:
201
+ self.pending_output.extend(new_events)
202
+ # Sort pending events by start time
203
+ self.pending_output.sort(key=lambda e: e.timestamp)
204
+
205
+ # 4. Safe Release Logic:
206
+ # We can only release events that started BEFORE the oldest active tone's start time.
207
+ # This guarantees that no future event will be generated with an EARLIER timestamp
208
+ # than what we release now.
209
+
210
+ ready_events: List[ToneEvent] = []
211
+
212
+ if not self.active_tones:
213
+ # No active tones -> Safe to release everything
214
+ ready_events = self.pending_output
215
+ self.pending_output = []
216
+ else:
217
+ # Find the oldest start time among active tones
218
+ min_active_start = min(t.start_time for t in self.active_tones)
219
+
220
+ # Release events that definitely happen before any potential new event
221
+ # (Note: allowing a small margin for float equality)
222
+ split_idx = 0
223
+ for i, event in enumerate(self.pending_output):
224
+ if event.timestamp < min_active_start:
225
+ split_idx = i + 1
226
+ else:
227
+ break
228
+
229
+ if split_idx > 0:
230
+ ready_events = self.pending_output[:split_idx]
231
+ self.pending_output = self.pending_output[split_idx:]
232
+
233
+ # 5. Coalesce overlapping ready events
234
+ if len(ready_events) > 1:
235
+ coalesced_events = []
236
+
237
+ if ready_events:
238
+ current_event = ready_events[0]
239
+
240
+ for next_event in ready_events[1:]:
241
+ # Check for overlap
242
+ current_end = current_event.timestamp + current_event.duration
243
+ next_start = next_event.timestamp
244
+
245
+ # If they overlap significantly (more than 50% of the shorter one)
246
+ overlap = max(
247
+ 0, min(current_end, next_event.timestamp + next_event.duration) - next_start
248
+ )
249
+ min_dur = min(current_event.duration, next_event.duration)
250
+
251
+ if overlap > self.coalesce_ratio * min_dur:
252
+ # Overlap detected - coalescing
253
+ if next_event.duration > current_event.duration:
254
+ current_event = next_event
255
+ else:
256
+ coalesced_events.append(current_event)
257
+ current_event = next_event
258
+
259
+ coalesced_events.append(current_event)
260
+ ready_events = coalesced_events
261
+
262
+ return ready_events
@@ -0,0 +1,228 @@
1
+ """State machine for matching event streams against alarm profiles."""
2
+
3
+ import logging
4
+ from typing import List, Optional, Tuple
5
+
6
+ from ..events import AudioEvent, PatternMatchEvent, ToneEvent
7
+ from ..models import AlarmProfile
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class MatcherState:
13
+ """Tracks progress of a single profile match."""
14
+
15
+ def __init__(self, profile: AlarmProfile):
16
+ self.profile = profile
17
+ self.current_segment_index = 0
18
+ self.cycle_count = 0
19
+ self.last_event_time = 0.0
20
+ self.start_time = 0.0
21
+
22
+ # Pre-compute the valid frequency range for this profile
23
+ self.freq_ranges: List[Tuple[float, float]] = []
24
+ for seg in profile.segments:
25
+ if seg.type == "tone" and seg.frequency:
26
+ self.freq_ranges.append((seg.frequency.min, seg.frequency.max))
27
+
28
+ def reset(self):
29
+ """Reset matching state."""
30
+ self.current_segment_index = 0
31
+ self.cycle_count = 0
32
+ self.last_event_time = 0.0
33
+
34
+ def is_relevant_frequency(self, freq: float) -> bool:
35
+ """Check if a frequency is within any expected range for this profile."""
36
+ for fmin, fmax in self.freq_ranges:
37
+ if fmin <= freq <= fmax:
38
+ return True
39
+ return False
40
+
41
+
42
+ class SequenceMatcher:
43
+ """Matches incoming events against multiple alarm profiles using a state machine.
44
+
45
+ This class maintains an independent tracking state for each monitored profile.
46
+ It receives audio events (like Tones) and sequentially verifies if they match
47
+ the expected segments (Tones/Silences) of an alarm pattern.
48
+
49
+ Key Features:
50
+ - **Noise Immunity**: Tones that do not match ANY expected frequency range
51
+ across the profile are ignored. This effectively makes the matcher "deaf"
52
+ to background noise like speech or music, provided they don't overlap
53
+ with alarm frequencies.
54
+ - **Parallel Matching**: Can track multiple optional profiles simultaneously.
55
+ - **Resilience**: Handles missing or imperfect events up to a certain tolerance.
56
+ """
57
+
58
+ def __init__(self, profiles: List[AlarmProfile]):
59
+ """Initialize with list of profiles to match against.
60
+
61
+ Args:
62
+ profiles: List of AlarmProfile objects defining the patterns to detect.
63
+ """
64
+ self.profiles = profiles
65
+ self.states = {p.name: MatcherState(p) for p in profiles}
66
+
67
+ def process(self, event: AudioEvent) -> List[PatternMatchEvent]:
68
+ """Process a new audio event and check for pattern matches.
69
+
70
+ This is the main entry point for the matcher. It updates the state
71
+ of every profile with the new event.
72
+
73
+ Args:
74
+ event: An AudioEvent (usually a ToneEvent) detected by the Generator.
75
+
76
+ Returns:
77
+ List of PatternMatchEvent objects for any profiles that completed
78
+ their full pattern match sequence with this event.
79
+ """
80
+ matches = []
81
+
82
+ for profile in self.profiles:
83
+ match_event = self._update_profile(self.states[profile.name], event)
84
+ if match_event:
85
+ matches.append(match_event)
86
+
87
+ return matches
88
+
89
+ def _update_profile(
90
+ self, state: MatcherState, event: AudioEvent
91
+ ) -> Optional[PatternMatchEvent]:
92
+ """Update matching state for a single profile.
93
+
94
+ Internal method that advances the state machine for a specific profile.
95
+
96
+ Args:
97
+ state: The tracking state for the profile.
98
+ event: The new audio event to evaluate.
99
+
100
+ Returns:
101
+ A PatternMatchEvent if the profile's confirmation cycles are complete,
102
+ otherwise None.
103
+ """
104
+ p = state.profile
105
+
106
+ if state.current_segment_index >= len(p.segments):
107
+ state.current_segment_index = 0
108
+
109
+ expected = p.segments[state.current_segment_index]
110
+
111
+ if isinstance(event, ToneEvent):
112
+ # KEY FIX: Ignore tones that don't match any expected frequency
113
+ # This filters out ambient noise that would break pattern matching
114
+ if not state.is_relevant_frequency(event.frequency):
115
+ # This tone is outside all expected ranges - ignore it completely
116
+ logger.debug(f"[{p.name}] Ignoring out-of-band tone: {event.frequency:.0f}Hz")
117
+ return None
118
+
119
+ # Check silence gap before this tone
120
+ gap_duration = event.timestamp - state.last_event_time
121
+
122
+ # If expecting silence, check if gap matches
123
+ if expected.type == "silence":
124
+ # Handle overlapping events (negative gap)
125
+ # If we have a negative gap, it means this new event started BEFORE the previous one ended.
126
+ # If this new event matches the PREVIOUSLY matched tone segment, we can treat it as part of that event
127
+ # and just extend the timeline, effectively merging them.
128
+ if gap_duration < 0 and state.current_segment_index > 0:
129
+ prev_idx = state.current_segment_index - 1
130
+ # Wrap around not needed because index increments only after silence
131
+ # Wait, if current is silence, previous was TONE.
132
+ prev_seg = p.segments[prev_idx]
133
+
134
+ if prev_seg.type == "tone" and prev_seg.frequency:
135
+ if prev_seg.frequency.contains(event.frequency):
136
+ # This is likely a parallel detection of the previous tone
137
+ logger.debug(
138
+ f"[{p.name}] Merging overlapping tone: {event.frequency:.0f}Hz "
139
+ f"(gap {gap_duration:.2f}s)"
140
+ )
141
+ # Extend the last event time if this one lasts longer
142
+ state.last_event_time = max(
143
+ state.last_event_time, event.timestamp + event.duration
144
+ )
145
+ return None
146
+
147
+ if expected.duration.contains(gap_duration):
148
+ state.current_segment_index += 1
149
+ logger.debug(f"[{p.name}] Silence matched: {gap_duration:.2f}s")
150
+
151
+ if state.current_segment_index >= len(p.segments):
152
+ state.cycle_count += 1
153
+ state.current_segment_index = 0
154
+ logger.debug(
155
+ f"[{p.name}] Cycle {state.cycle_count}/{p.confirmation_cycles} complete"
156
+ )
157
+
158
+ if state.cycle_count >= p.confirmation_cycles:
159
+ state.cycle_count = 0
160
+ return PatternMatchEvent(
161
+ timestamp=event.timestamp,
162
+ duration=0,
163
+ profile_name=p.name,
164
+ cycle_count=p.confirmation_cycles,
165
+ )
166
+
167
+ expected = p.segments[state.current_segment_index]
168
+ else:
169
+ if state.current_segment_index > 0:
170
+ logger.debug(
171
+ f"[{p.name}] Reset: Gap {gap_duration:.2f}s doesn't match "
172
+ f"expected {expected.duration.min:.2f}-{expected.duration.max:.2f}s"
173
+ )
174
+ state.reset()
175
+ expected = p.segments[0]
176
+
177
+ # Now check if this tone matches current expectation
178
+ is_match = False
179
+ if expected.type == "tone" and expected.frequency:
180
+ freq_match = expected.frequency.contains(event.frequency)
181
+ dur_match = expected.duration.contains(event.duration)
182
+
183
+ if freq_match and dur_match:
184
+ is_match = True
185
+ logger.debug(
186
+ f"[{p.name}] Tone matched step {state.current_segment_index}: "
187
+ f"{event.frequency:.0f}Hz, {event.duration:.2f}s"
188
+ )
189
+ elif freq_match and not dur_match:
190
+ # Frequency matches but duration doesn't - reset
191
+ logger.debug(
192
+ f"[{p.name}] Duration mismatch: got {event.duration:.2f}s, "
193
+ f"expected {expected.duration.min:.2f}-{expected.duration.max:.2f}s"
194
+ )
195
+ if state.current_segment_index > 0:
196
+ state.reset()
197
+ expected = p.segments[0]
198
+ # Try matching step 0 with this event
199
+ if (
200
+ expected.type == "tone"
201
+ and expected.frequency
202
+ and expected.frequency.contains(event.frequency)
203
+ and expected.duration.contains(event.duration)
204
+ ):
205
+ is_match = True
206
+
207
+ # Advance state if matched
208
+ if is_match:
209
+ state.last_event_time = event.timestamp + event.duration
210
+ state.current_segment_index += 1
211
+
212
+ if state.current_segment_index >= len(p.segments):
213
+ state.cycle_count += 1
214
+ state.current_segment_index = 0
215
+ logger.debug(
216
+ f"[{p.name}] Cycle {state.cycle_count}/{p.confirmation_cycles} complete"
217
+ )
218
+
219
+ if state.cycle_count >= p.confirmation_cycles:
220
+ state.cycle_count = 0
221
+ return PatternMatchEvent(
222
+ timestamp=event.timestamp,
223
+ duration=0,
224
+ profile_name=p.name,
225
+ cycle_count=p.confirmation_cycles,
226
+ )
227
+
228
+ return None