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.
- acoustic_engine/__init__.py +54 -0
- acoustic_engine/analysis/__init__.py +0 -0
- acoustic_engine/analysis/event_buffer.py +85 -0
- acoustic_engine/analysis/generator.py +262 -0
- acoustic_engine/analysis/matcher.py +228 -0
- acoustic_engine/analysis/windowed_matcher.py +358 -0
- acoustic_engine/config.py +453 -0
- acoustic_engine/engine.py +294 -0
- acoustic_engine/events.py +50 -0
- acoustic_engine/input/__init__.py +0 -0
- acoustic_engine/input/listener.py +205 -0
- acoustic_engine/models.py +103 -0
- acoustic_engine/parallel_engine.py +169 -0
- acoustic_engine/processing/__init__.py +0 -0
- acoustic_engine/processing/dsp.py +166 -0
- acoustic_engine/processing/filter.py +101 -0
- acoustic_engine/profiles.py +221 -0
- acoustic_engine/runner.py +138 -0
- acoustic_engine/tester/__init__.py +206 -0
- acoustic_engine/tester/__main__.py +9 -0
- acoustic_engine/tester/display.py +198 -0
- acoustic_engine/tester/mixer.py +166 -0
- acoustic_engine/tester/runner.py +384 -0
- acoustic_engine/tuner/__init__.py +61 -0
- acoustic_engine/tuner/__main__.py +9 -0
- acoustic_engine/tuner/app.js +1617 -0
- acoustic_engine/tuner/audio-engine.js +1222 -0
- acoustic_engine/tuner/check_braces.py +24 -0
- acoustic_engine/tuner/index.html +392 -0
- acoustic_engine/tuner/styles.css +1751 -0
- acoustic_engine/tuner/visualizer.js +861 -0
- acoustic_engine-1.0.0.dist-info/METADATA +574 -0
- acoustic_engine-1.0.0.dist-info/RECORD +37 -0
- acoustic_engine-1.0.0.dist-info/WHEEL +5 -0
- acoustic_engine-1.0.0.dist-info/entry_points.txt +2 -0
- acoustic_engine-1.0.0.dist-info/licenses/LICENSE +411 -0
- 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
|