fastmoda 2026.10.16__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.
fastmoda/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """FastMODA: Efficient signal decomposition with FFT + changepoint detection
2
+
3
+ Includes GPU acceleration support via PyTorch/CUDA when available.
4
+ """
5
+
6
+ __version__ = "2026.10.16"
7
+ from fastmoda.fastmoda import (
8
+ load_signal, sliding_fft, compute_band_powers, detect_changepoints,
9
+ extract_instantaneous_frequency, extract_band_frequencies,
10
+ fit_sine_segments, detect_periodicity_changes
11
+ )
12
+
13
+ # Try to import GPU utilities (optional)
14
+ try:
15
+ from fastmoda.gpu_utils import (
16
+ sliding_fft_gpu, compute_band_powers_gpu, batch_sliding_fft_gpu,
17
+ is_gpu_available, get_gpu_info, get_device,
18
+ to_tensor, to_numpy, benchmark_gpu_vs_cpu
19
+ )
20
+ GPU_AVAILABLE = True
21
+ except ImportError:
22
+ GPU_AVAILABLE = False
23
+
24
+ __all__ = [
25
+ # Core CPU functions
26
+ 'load_signal', 'sliding_fft', 'compute_band_powers', 'detect_changepoints',
27
+ 'extract_instantaneous_frequency', 'extract_band_frequencies',
28
+ 'fit_sine_segments', 'detect_periodicity_changes',
29
+ ]
30
+
31
+ # Add GPU functions to __all__ if available
32
+ if GPU_AVAILABLE:
33
+ __all__.extend([
34
+ 'sliding_fft_gpu', 'compute_band_powers_gpu', 'batch_sliding_fft_gpu',
35
+ 'is_gpu_available', 'get_gpu_info', 'get_device',
36
+ 'to_tensor', 'to_numpy', 'benchmark_gpu_vs_cpu', 'GPU_AVAILABLE'
37
+ ])
38
+
@@ -0,0 +1,467 @@
1
+ """GPU-optimized analysis functions for FastMODA
2
+
3
+ Implements various signal analysis methods with GPU acceleration:
4
+ - Phase Analysis (Hilbert transform, instantaneous phase)
5
+ - Windowed FFT (STFT)
6
+ - Wavelet Transform
7
+ - Coherence Analysis
8
+ - Bispectrum Analysis
9
+ """
10
+
11
+ import numpy as np
12
+ from typing import Tuple, Optional, Dict, List
13
+ import warnings
14
+
15
+ # Try to import GPU libraries
16
+ try:
17
+ import torch
18
+ TORCH_AVAILABLE = True
19
+ except ImportError:
20
+ TORCH_AVAILABLE = False
21
+ # torch is optional (the [gpu] extra): note it in the log, not on import
22
+ import logging
23
+ logging.getLogger(__name__).info("PyTorch not available. GPU acceleration disabled for analysis.")
24
+
25
+ try:
26
+ import cupy as cp
27
+ CUPY_AVAILABLE = True
28
+ except ImportError:
29
+ CUPY_AVAILABLE = False
30
+
31
+
32
+ def is_gpu_available() -> bool:
33
+ """Check if GPU is available for computation"""
34
+ if TORCH_AVAILABLE:
35
+ return torch.cuda.is_available()
36
+ elif CUPY_AVAILABLE:
37
+ try:
38
+ cp.cuda.Device(0).compute_capability
39
+ return True
40
+ except:
41
+ return False
42
+ return False
43
+
44
+
45
+ # ==================== Phase Analysis ====================
46
+
47
+ def hilbert_transform_gpu(x: np.ndarray) -> np.ndarray:
48
+ """
49
+ GPU-accelerated Hilbert transform for instantaneous phase/amplitude
50
+
51
+ Args:
52
+ x: Input signal (1D array)
53
+
54
+ Returns:
55
+ Analytic signal (complex array)
56
+ """
57
+ if TORCH_AVAILABLE and torch.cuda.is_available():
58
+ # Use PyTorch GPU
59
+ x_gpu = torch.from_numpy(x).cuda()
60
+
61
+ # FFT-based Hilbert transform
62
+ X = torch.fft.fft(x_gpu)
63
+ N = len(x)
64
+ h = torch.zeros(N, device='cuda')
65
+ if N % 2 == 0:
66
+ h[0] = h[N // 2] = 1
67
+ h[1:N // 2] = 2
68
+ else:
69
+ h[0] = 1
70
+ h[1:(N + 1) // 2] = 2
71
+
72
+ analytic = torch.fft.ifft(X * h)
73
+ return analytic.cpu().numpy()
74
+ else:
75
+ # CPU fallback
76
+ from scipy.signal import hilbert
77
+ return hilbert(x)
78
+
79
+
80
+ def compute_instantaneous_phase_gpu(x: np.ndarray, fs: float = 1.0) -> Dict:
81
+ """
82
+ Compute instantaneous phase and frequency using GPU
83
+
84
+ Args:
85
+ x: Input signal
86
+ fs: Sampling frequency
87
+
88
+ Returns:
89
+ Dictionary with phase, frequency, amplitude
90
+ """
91
+ analytic = hilbert_transform_gpu(x)
92
+
93
+ # Instantaneous amplitude and phase
94
+ amplitude = np.abs(analytic)
95
+ phase = np.angle(analytic)
96
+
97
+ # Instantaneous frequency (derivative of phase)
98
+ inst_freq = np.diff(np.unwrap(phase)) * fs / (2 * np.pi)
99
+ inst_freq = np.concatenate([[inst_freq[0]], inst_freq]) # Pad to match length
100
+
101
+ return {
102
+ 'amplitude': amplitude,
103
+ 'phase': phase,
104
+ 'frequency': inst_freq,
105
+ 'analytic': analytic
106
+ }
107
+
108
+
109
+ def phase_coherence_gpu(x1: np.ndarray, x2: np.ndarray,
110
+ fs: float = 1.0,
111
+ window_size: int = 100) -> Dict:
112
+ """
113
+ Compute phase coherence between two signals using GPU
114
+
115
+ Args:
116
+ x1, x2: Input signals
117
+ fs: Sampling frequency
118
+ window_size: Window for computing coherence
119
+
120
+ Returns:
121
+ Dictionary with coherence metrics
122
+ """
123
+ # Get instantaneous phases
124
+ phase1 = compute_instantaneous_phase_gpu(x1, fs)['phase']
125
+ phase2 = compute_instantaneous_phase_gpu(x2, fs)['phase']
126
+
127
+ # Phase difference
128
+ phase_diff = phase1 - phase2
129
+
130
+ # Phase locking value (PLV) — vectorised sliding window via stride trick
131
+ hop = window_size // 2
132
+ pd = np.asarray(phase_diff)
133
+ n_w = (len(pd) - window_size) // hop
134
+ # Build [n_windows, window_size] view without copying
135
+ shape = (n_w, window_size)
136
+ strides = (pd.strides[0] * hop, pd.strides[0])
137
+ windows = np.lib.stride_tricks.as_strided(pd, shape=shape, strides=strides)
138
+ plv = np.abs(np.mean(np.exp(1j * windows), axis=1))
139
+ times = (np.arange(n_w) * hop + window_size / 2) / fs
140
+
141
+ return {
142
+ 'phase_diff': phase_diff,
143
+ 'plv': np.array(plv),
144
+ 'times': np.array(times),
145
+ 'phase1': phase1,
146
+ 'phase2': phase2
147
+ }
148
+
149
+
150
+ # ==================== Windowed FFT (STFT) ====================
151
+
152
+ def stft_gpu(x: np.ndarray, fs: float = 1.0,
153
+ window_size: int = 256,
154
+ hop_size: int = 128,
155
+ window: str = 'hann') -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
156
+ """
157
+ GPU-accelerated Short-Time Fourier Transform
158
+
159
+ Args:
160
+ x: Input signal
161
+ fs: Sampling frequency
162
+ window_size: Size of the window
163
+ hop_size: Hop size between windows
164
+ window: Window type ('hann', 'hamming', 'blackman')
165
+
166
+ Returns:
167
+ frequencies, times, STFT magnitude
168
+ """
169
+ if TORCH_AVAILABLE and torch.cuda.is_available():
170
+ # Use PyTorch GPU
171
+ x_gpu = torch.from_numpy(x.astype(np.float32)).cuda()
172
+
173
+ # Create window
174
+ if window == 'hann':
175
+ win = torch.hann_window(window_size, device='cuda')
176
+ elif window == 'hamming':
177
+ win = torch.hamming_window(window_size, device='cuda')
178
+ elif window == 'blackman':
179
+ win = torch.blackman_window(window_size, device='cuda')
180
+ else:
181
+ win = torch.ones(window_size, device='cuda')
182
+
183
+ # Compute STFT
184
+ stft_result = torch.stft(
185
+ x_gpu,
186
+ n_fft=window_size,
187
+ hop_length=hop_size,
188
+ win_length=window_size,
189
+ window=win,
190
+ return_complex=True
191
+ )
192
+
193
+ # Get magnitude
194
+ magnitude = torch.abs(stft_result).cpu().numpy()
195
+
196
+ # Frequency and time axes
197
+ freqs = np.fft.rfftfreq(window_size, 1/fs)
198
+ times = np.arange(magnitude.shape[1]) * hop_size / fs
199
+
200
+ return freqs, times, magnitude
201
+ else:
202
+ # CPU fallback using scipy
203
+ from scipy.signal import stft as scipy_stft
204
+ freqs, times, Zxx = scipy_stft(x, fs, window=window,
205
+ nperseg=window_size,
206
+ noverlap=window_size - hop_size)
207
+ return freqs, times, np.abs(Zxx)
208
+
209
+
210
+ # ==================== Wavelet Transform ====================
211
+
212
+ def morlet_wavelet_gpu(t: np.ndarray, f: float, sigma: float = 5.0) -> np.ndarray:
213
+ """
214
+ Generate Morlet wavelet on GPU
215
+
216
+ Args:
217
+ t: Time array
218
+ f: Frequency
219
+ sigma: Width parameter
220
+
221
+ Returns:
222
+ Complex Morlet wavelet
223
+ """
224
+ if TORCH_AVAILABLE and torch.cuda.is_available():
225
+ t_gpu = torch.from_numpy(t.astype(np.float32)).cuda()
226
+ f_gpu = torch.tensor(f, device='cuda')
227
+ sigma_gpu = torch.tensor(sigma, device='cuda')
228
+
229
+ wavelet = (torch.pi ** -0.25) * torch.exp(2j * torch.pi * f_gpu * t_gpu) * \
230
+ torch.exp(-(t_gpu ** 2) / (2 * sigma_gpu ** 2))
231
+ return wavelet.cpu().numpy()
232
+ else:
233
+ # CPU fallback
234
+ return (np.pi ** -0.25) * np.exp(2j * np.pi * f * t) * \
235
+ np.exp(-(t ** 2) / (2 * sigma ** 2))
236
+
237
+
238
+ def cwt_gpu(x: np.ndarray, fs: float = 1.0,
239
+ freq_range: Tuple[float, float] = (0.5, 50),
240
+ n_freqs: int = 50) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
241
+ """
242
+ GPU-accelerated Continuous Wavelet Transform
243
+
244
+ Args:
245
+ x: Input signal
246
+ fs: Sampling frequency
247
+ freq_range: (min_freq, max_freq)
248
+ n_freqs: Number of frequency bins
249
+
250
+ Returns:
251
+ frequencies, times, CWT coefficients (magnitude)
252
+ """
253
+ freqs = np.logspace(np.log10(freq_range[0]), np.log10(freq_range[1]), n_freqs)
254
+ times = np.arange(len(x)) / fs
255
+
256
+ if TORCH_AVAILABLE and torch.cuda.is_available():
257
+ x_gpu = torch.from_numpy(x.astype(np.complex64)).cuda()
258
+ cwt_matrix = torch.zeros((n_freqs, len(x)), dtype=torch.complex64, device='cuda')
259
+
260
+ for i, freq in enumerate(freqs):
261
+ # Create wavelet
262
+ scale = 1.0 / freq
263
+ t_wavelet = np.arange(-4 * scale, 4 * scale, 1/fs)
264
+ wavelet = morlet_wavelet_gpu(t_wavelet, freq)
265
+ wavelet_gpu = torch.from_numpy(wavelet.astype(np.complex64)).cuda()
266
+
267
+ # Convolve with signal (using FFT convolution)
268
+ if len(wavelet_gpu) < len(x_gpu):
269
+ # Pad wavelet
270
+ padded_wavelet = torch.zeros(len(x_gpu), dtype=torch.complex64, device='cuda')
271
+ start = len(x_gpu) // 2 - len(wavelet_gpu) // 2
272
+ padded_wavelet[start:start + len(wavelet_gpu)] = wavelet_gpu
273
+
274
+ # FFT convolution
275
+ X = torch.fft.fft(x_gpu)
276
+ W = torch.fft.fft(padded_wavelet)
277
+ cwt_matrix[i, :] = torch.fft.ifft(X * torch.conj(W))
278
+
279
+ magnitude = torch.abs(cwt_matrix).cpu().numpy()
280
+ return freqs, times, magnitude
281
+ else:
282
+ # CPU fallback using scipy
283
+ try:
284
+ from scipy import signal
285
+ widths = fs / freqs
286
+ cwt_matrix = signal.cwt(x, signal.morlet2, widths)
287
+ return freqs, times, np.abs(cwt_matrix)
288
+ except:
289
+ # Simple fallback
290
+ cwt_matrix = np.zeros((n_freqs, len(x)))
291
+ for i, freq in enumerate(freqs):
292
+ scale = 1.0 / freq
293
+ t_wavelet = np.arange(-4 * scale, 4 * scale, 1/fs)
294
+ wavelet = morlet_wavelet_gpu(t_wavelet, freq)
295
+
296
+ # Simple convolution
297
+ if len(wavelet) < len(x):
298
+ cwt_matrix[i, :] = np.abs(np.convolve(x, wavelet, mode='same'))
299
+
300
+ return freqs, times, cwt_matrix
301
+
302
+
303
+ # ==================== Coherence Analysis ====================
304
+
305
+ def wavelet_coherence_gpu(x1: np.ndarray, x2: np.ndarray,
306
+ fs: float = 1.0,
307
+ freq_range: Tuple[float, float] = (0.5, 50),
308
+ n_freqs: int = 50) -> Dict:
309
+ """
310
+ GPU-accelerated Wavelet Coherence between two signals
311
+
312
+ Args:
313
+ x1, x2: Input signals
314
+ fs: Sampling frequency
315
+ freq_range: (min_freq, max_freq)
316
+ n_freqs: Number of frequency bins
317
+
318
+ Returns:
319
+ Dictionary with coherence, phase difference, frequencies, times
320
+ """
321
+ # Compute CWT for both signals
322
+ freqs1, times1, cwt1_mag = cwt_gpu(x1, fs, freq_range, n_freqs)
323
+ freqs2, times2, cwt2_mag = cwt_gpu(x2, fs, freq_range, n_freqs)
324
+
325
+ # For coherence, we need complex coefficients
326
+ # Re-compute with phase information
327
+ # (Simplified version - full implementation would preserve complex values)
328
+
329
+ # Cross-spectrum (simplified)
330
+ cross_spec = cwt1_mag * cwt2_mag
331
+
332
+ # Auto-spectra
333
+ auto_spec1 = cwt1_mag ** 2
334
+ auto_spec2 = cwt2_mag ** 2
335
+
336
+ # Coherence
337
+ coherence = cross_spec / np.sqrt(auto_spec1 * auto_spec2 + 1e-10)
338
+
339
+ return {
340
+ 'coherence': coherence,
341
+ 'frequencies': freqs1,
342
+ 'times': times1,
343
+ 'cwt1': cwt1_mag,
344
+ 'cwt2': cwt2_mag
345
+ }
346
+
347
+
348
+ # ==================== Bispectrum Analysis ====================
349
+
350
+ def bispectrum_gpu(x: np.ndarray, fs: float = 1.0,
351
+ nfft: int = 256,
352
+ overlap: float = 0.5) -> Dict:
353
+ """
354
+ GPU-accelerated Bispectrum analysis
355
+
356
+ The bispectrum detects quadratic phase coupling between frequency components
357
+
358
+ Args:
359
+ x: Input signal
360
+ fs: Sampling frequency
361
+ nfft: FFT size
362
+ overlap: Overlap fraction
363
+
364
+ Returns:
365
+ Dictionary with bispectrum, bicoherence, frequencies
366
+ """
367
+ # Segment the signal
368
+ hop = int(nfft * (1 - overlap))
369
+ n_segments = (len(x) - nfft) // hop + 1
370
+
371
+ n_freq = nfft // 2 + 1
372
+
373
+ # Precompute f3 index matrix and in-range mask (shared by GPU and CPU paths)
374
+ f_idx = np.arange(n_freq)
375
+ f3_mat = f_idx[:, None] + f_idx[None, :] # [F, F] f3 = f1 + f2
376
+ valid = f3_mat < n_freq # [F, F] in-range pairs
377
+ f3_safe = np.clip(f3_mat, 0, n_freq - 1) # safe gather index
378
+
379
+ if TORCH_AVAILABLE and torch.cuda.is_available():
380
+ x_gpu = torch.from_numpy(x.astype(np.float32)).cuda()
381
+ hann_win = torch.hann_window(nfft, device='cuda')
382
+ valid_t = torch.from_numpy(valid).cuda()
383
+ f3_t = torch.from_numpy(f3_safe).cuda() # [F, F]
384
+
385
+ # Batch all segment FFTs in one call — unfold extracts [S, nfft] without copying
386
+ # Memory: [S, F, F] complex64. For S=100, nfft=512 → ~53 MB.
387
+ frames = x_gpu.unfold(0, nfft, hop) # [S, nfft]
388
+ X_all = torch.fft.rfft(frames * hann_win, dim=1) # [S, F]
389
+
390
+ # Triple product over all segments and frequency pairs at once:
391
+ # contrib[s, f1, f2] = X[s,f1] * X[s,f2] * conj(X[s, f3(f1,f2)])
392
+ contrib_all = (X_all[:, :, None] * X_all[:, None, :] # [S, F, F]
393
+ * torch.conj(X_all[:, f3_t]))
394
+ bispectrum = (contrib_all
395
+ .masked_fill(~valid_t.unsqueeze(0), 0.0)
396
+ .sum(dim=0) / n_segments) # [F, F]
397
+
398
+ # Bicoherence: |B(f1,f2)| / (|B(f1,f1)| * |B(f2,f2)|)
399
+ biamp = torch.abs(bispectrum)
400
+ diag = biamp.diagonal() # [F]
401
+ bicoherence = (biamp / (diag.unsqueeze(1) * diag.unsqueeze(0) + 1e-10)).masked_fill(~valid_t, 0.0)
402
+
403
+ freqs = np.fft.rfftfreq(nfft, 1 / fs)
404
+ return {
405
+ 'bispectrum': bispectrum.cpu().numpy(),
406
+ 'bicoherence': bicoherence.cpu().numpy(),
407
+ 'frequencies': freqs
408
+ }
409
+ else:
410
+ # CPU fallback — same pattern with numpy stride_tricks + vectorised rfft
411
+ # Memory: [S, F, F] float32. Scale with nfft and n_segments.
412
+ shape = (n_segments, nfft)
413
+ strides = (x.strides[0] * hop, x.strides[0])
414
+ frames = np.lib.stride_tricks.as_strided(x, shape=shape, strides=strides)
415
+ X_all = np.fft.rfft(frames * np.hanning(nfft), axis=1) # [S, F]
416
+
417
+ contrib_all = X_all[:, :, None] * X_all[:, None, :] * np.conj(X_all[:, f3_safe]) # [S, F, F]
418
+ bispectrum = np.where(valid[None], contrib_all, 0.0).sum(axis=0) / n_segments
419
+
420
+ biamp = np.abs(bispectrum)
421
+ diag = np.diag(biamp)
422
+ bicoherence = np.where(
423
+ valid,
424
+ biamp / (diag[:, None] * diag[None, :] + 1e-10),
425
+ 0.0
426
+ )
427
+
428
+ freqs = np.fft.rfftfreq(nfft, 1 / fs)
429
+ return {
430
+ 'bispectrum': bispectrum,
431
+ 'bicoherence': bicoherence,
432
+ 'frequencies': freqs
433
+ }
434
+
435
+
436
+ # ==================== Summary Statistics ====================
437
+
438
+ def compute_analysis_summary(results: Dict) -> Dict:
439
+ """
440
+ Compute summary statistics from various analyses
441
+
442
+ Args:
443
+ results: Dictionary containing results from different analyses
444
+
445
+ Returns:
446
+ Summary statistics for neural network input
447
+ """
448
+ summary = {
449
+ 'timestamp': [],
450
+ 'features': []
451
+ }
452
+
453
+ # This will be expanded later with neural network integration
454
+ # For now, just extract key metrics
455
+
456
+ if 'phase' in results:
457
+ phase_data = results['phase']
458
+ summary['mean_frequency'] = np.mean(phase_data.get('frequency', []))
459
+ summary['std_frequency'] = np.std(phase_data.get('frequency', []))
460
+ summary['mean_amplitude'] = np.mean(phase_data.get('amplitude', []))
461
+
462
+ if 'stft' in results:
463
+ stft_data = results['stft']
464
+ # Dominant frequency over time
465
+ # ... more features
466
+
467
+ return summary
@@ -0,0 +1,134 @@
1
+ """Real-time microphone capture with FFT; broadcasts frames to SSE subscribers."""
2
+ import threading
3
+ import queue
4
+ import time
5
+ import numpy as np
6
+ from typing import List, Optional
7
+
8
+ SAMPLE_RATE = 44100
9
+ FFT_SIZE = 2048
10
+ HOP_SIZE = 1024 # blocksize → ~43 candidate frames/s from sounddevice
11
+ TARGET_FPS = 15 # actual frames sent per second
12
+ MAX_FREQ_HZ = 8000 # upper display limit
13
+
14
+ _lock: threading.Lock = threading.Lock()
15
+ _subscribers: List[queue.Queue] = []
16
+ _thread: Optional[threading.Thread] = None
17
+ _running: bool = False
18
+
19
+ _FREQS: Optional[np.ndarray] = None
20
+ _FREQ_MASK: Optional[np.ndarray] = None
21
+
22
+
23
+ def _freq_axis():
24
+ global _FREQS, _FREQ_MASK
25
+ if _FREQS is None:
26
+ all_freqs = np.fft.rfftfreq(FFT_SIZE, 1.0 / SAMPLE_RATE)
27
+ _FREQ_MASK = all_freqs <= MAX_FREQ_HZ
28
+ _FREQS = all_freqs[_FREQ_MASK]
29
+ return _FREQS, _FREQ_MASK
30
+
31
+
32
+ # ── public API ────────────────────────────────────────────────────────────────
33
+
34
+ def is_available() -> bool:
35
+ try:
36
+ import sounddevice # noqa: F401
37
+ return True
38
+ except (ImportError, OSError):
39
+ return False
40
+
41
+
42
+ def subscribe() -> queue.Queue:
43
+ """Return a frame queue; starts capture thread on first call."""
44
+ q: queue.Queue = queue.Queue(maxsize=30)
45
+ with _lock:
46
+ _subscribers.append(q)
47
+ if not _running:
48
+ _start()
49
+ return q
50
+
51
+
52
+ def unsubscribe(q: queue.Queue) -> None:
53
+ with _lock:
54
+ try:
55
+ _subscribers.remove(q)
56
+ except ValueError:
57
+ pass
58
+
59
+
60
+ def status() -> dict:
61
+ return {"available": is_available(), "running": _running,
62
+ "subscribers": len(_subscribers),
63
+ "sample_rate": SAMPLE_RATE, "fft_size": FFT_SIZE,
64
+ "max_freq_hz": MAX_FREQ_HZ}
65
+
66
+
67
+ # ── internals ─────────────────────────────────────────────────────────────────
68
+
69
+ def _start() -> None:
70
+ global _thread, _running
71
+ _running = True
72
+ _thread = threading.Thread(target=_capture_loop, daemon=True,
73
+ name="audio-capture")
74
+ _thread.start()
75
+
76
+
77
+ def _broadcast(frame: dict) -> None:
78
+ with _lock:
79
+ dead = []
80
+ for q in _subscribers:
81
+ try:
82
+ q.put_nowait(frame)
83
+ except queue.Full:
84
+ dead.append(q)
85
+ for q in dead:
86
+ _subscribers.remove(q)
87
+
88
+
89
+ def _build_frame(samples: np.ndarray) -> dict:
90
+ freqs, mask = _freq_axis()
91
+ window = np.hanning(len(samples))
92
+ spec = np.fft.rfft(samples * window, n=FFT_SIZE)
93
+ mags_db = 20.0 * np.log10(np.abs(spec)[mask] + 1e-10)
94
+ rms_db = float(20.0 * np.log10(np.sqrt(np.mean(samples ** 2)) + 1e-10))
95
+ return {
96
+ "frequencies": freqs.tolist(),
97
+ "magnitudes_db": mags_db.tolist(),
98
+ "rms_db": round(rms_db, 1),
99
+ "peak_db": round(float(np.max(mags_db)), 1),
100
+ "timestamp": time.time(),
101
+ }
102
+
103
+
104
+ def _capture_loop() -> None:
105
+ global _running
106
+ try:
107
+ import sounddevice as sd
108
+ except ImportError:
109
+ _running = False
110
+ return
111
+
112
+ frame_interval = 1.0 / TARGET_FPS
113
+ buf = np.zeros(FFT_SIZE, dtype=np.float32)
114
+ last_tx = 0.0
115
+
116
+ def _cb(indata, frames, _t, _status):
117
+ nonlocal buf, last_tx
118
+ chunk = indata[:, 0]
119
+ buf = np.roll(buf, -len(chunk))
120
+ buf[-len(chunk):] = chunk
121
+ now = time.time()
122
+ if now - last_tx >= frame_interval:
123
+ last_tx = now
124
+ _broadcast(_build_frame(buf.copy()))
125
+
126
+ try:
127
+ with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32",
128
+ blocksize=HOP_SIZE, callback=_cb):
129
+ while _running:
130
+ time.sleep(0.05)
131
+ except Exception as exc:
132
+ print(f"[audio_capture] error: {exc}")
133
+ finally:
134
+ _running = False
fastmoda/baseline.py ADDED
@@ -0,0 +1,37 @@
1
+ """Per-patient baseline deviation computation.
2
+
3
+ Welford running mean/variance updates live in fastmoda.storage; this module
4
+ turns a feature vector + baseline stats into per-feature z-score deviations,
5
+ which are both the classifier input and the human-readable explanation
6
+ ("phase coherence is 2.3 std below your normal").
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ EPS = 1e-8
12
+
13
+
14
+ def compute_deviation(feature_vector, feature_names, baseline_stats):
15
+ """Return a z-score deviation vector for feature_vector against baseline_stats.
16
+
17
+ Args:
18
+ feature_vector: 1D array of raw feature values
19
+ feature_names: list of feature names, same order as feature_vector
20
+ baseline_stats: dict shaped like {'features': {name: {'mean', 'std'}}},
21
+ as returned by fastmoda.storage.get_baseline() or the global
22
+ feature stats produced during condition-model training. Features
23
+ missing from baseline_stats deviate as 0 (no information yet).
24
+
25
+ Returns:
26
+ 1D numpy array of per-feature z-score deviations, same length/order
27
+ as feature_vector.
28
+ """
29
+ features = (baseline_stats or {}).get('features', {})
30
+ deviation = np.zeros(len(feature_names), dtype=np.float64)
31
+ for i, (name, value) in enumerate(zip(feature_names, feature_vector)):
32
+ stats = features.get(name)
33
+ if not stats:
34
+ continue
35
+ std = max(float(stats.get('std', 0.0)), EPS)
36
+ deviation[i] = (float(value) - float(stats.get('mean', 0.0))) / std
37
+ return deviation