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 +38 -0
- fastmoda/analysis_gpu.py +467 -0
- fastmoda/audio_capture.py +134 -0
- fastmoda/baseline.py +37 -0
- fastmoda/bayesian_full_gpu.py +399 -0
- fastmoda/bayesian_gpu.py +369 -0
- fastmoda/biphase_gpu.py +239 -0
- fastmoda/bispectrum_gpu.py +337 -0
- fastmoda/changepoint.py +159 -0
- fastmoda/coherence_gpu.py +279 -0
- fastmoda/concurrency.py +73 -0
- fastmoda/condition_models.py +123 -0
- fastmoda/coupling_gpu.py +308 -0
- fastmoda/diagnosis_network.py +488 -0
- fastmoda/fastmoda.py +375 -0
- fastmoda/feature_extraction.py +467 -0
- fastmoda/filtering.py +312 -0
- fastmoda/gpu_utils.py +248 -0
- fastmoda/job_status.py +119 -0
- fastmoda/legacy_bayes.py +503 -0
- fastmoda/legacy_moda.py +917 -0
- fastmoda/legacy_ridge.py +413 -0
- fastmoda/modwt_gpu.py +366 -0
- fastmoda/optimized.py +387 -0
- fastmoda/optimized_gpu.py +344 -0
- fastmoda/pipeline.py +114 -0
- fastmoda/preprocess.py +116 -0
- fastmoda/ridge_gpu.py +472 -0
- fastmoda/spectral_bins.py +88 -0
- fastmoda/storage.py +379 -0
- fastmoda/surrogates.py +482 -0
- fastmoda/surrogates_gpu.py +492 -0
- fastmoda-2026.10.16.dist-info/METADATA +192 -0
- fastmoda-2026.10.16.dist-info/RECORD +37 -0
- fastmoda-2026.10.16.dist-info/WHEEL +5 -0
- fastmoda-2026.10.16.dist-info/licenses/LICENSE +674 -0
- fastmoda-2026.10.16.dist-info/top_level.txt +1 -0
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
|
+
|
fastmoda/analysis_gpu.py
ADDED
|
@@ -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
|