polyergalio 0.1.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 (60) hide show
  1. polyergalio/__init__.py +12 -0
  2. polyergalio/distances.py +112 -0
  3. polyergalio/encoders/__init__.py +3 -0
  4. polyergalio/encoders/audio_encoders.py +484 -0
  5. polyergalio/encoders/categorical_encoders.py +201 -0
  6. polyergalio/encoders/chronologic_encoders.py +213 -0
  7. polyergalio/encoders/encoder_constants.py +23 -0
  8. polyergalio/encoders/encoder_utils.py +294 -0
  9. polyergalio/encoders/encoders.py +62 -0
  10. polyergalio/encoders/numeric_encoders.py +235 -0
  11. polyergalio/encoders/pipeline.py +671 -0
  12. polyergalio/encoders/tokenizer.py +622 -0
  13. polyergalio/encoders/tokenizer_fitting.py +167 -0
  14. polyergalio/generators/__init__.py +4 -0
  15. polyergalio/generators/data_generators.py +1012 -0
  16. polyergalio/generators/periodic_signal_gen.py +59 -0
  17. polyergalio/models/__init__.py +3 -0
  18. polyergalio/models/activations.py +200 -0
  19. polyergalio/models/clustering/__init__.py +3 -0
  20. polyergalio/models/clustering/centroid_network.py +599 -0
  21. polyergalio/models/clustering/cluster_metrics.py +340 -0
  22. polyergalio/models/clustering/freeplsom_clustering.py +572 -0
  23. polyergalio/models/clustering/gplsom_clustering.py +510 -0
  24. polyergalio/models/clustering/plsom_clustering.py +685 -0
  25. polyergalio/models/clustering/plsom_utils.py +245 -0
  26. polyergalio/models/constants.py +75 -0
  27. polyergalio/models/embedding/__init__.py +0 -0
  28. polyergalio/models/embedding/embedding.py +128 -0
  29. polyergalio/models/embedding/positional.py +323 -0
  30. polyergalio/models/layers/__init__.py +1 -0
  31. polyergalio/models/layers/basal_layers.py +744 -0
  32. polyergalio/models/layers/decision_layers.py +495 -0
  33. polyergalio/models/layers/fft_layers.py +370 -0
  34. polyergalio/models/layers/mixture_layers.py +647 -0
  35. polyergalio/models/layers/operator_layers.py +612 -0
  36. polyergalio/models/layers/spectre_layers.py +1344 -0
  37. polyergalio/models/layers/wavelet_layers.py +453 -0
  38. polyergalio/models/model_loss.py +395 -0
  39. polyergalio/models/neural_network.py +724 -0
  40. polyergalio/models/optimizers.py +142 -0
  41. polyergalio/models/supervised/__init__.py +3 -0
  42. polyergalio/models/supervised/relative_weights.py +196 -0
  43. polyergalio/models/supervised/scg_regression.py +563 -0
  44. polyergalio/models/supervised/trees/__init__.py +0 -0
  45. polyergalio/models/supervised/trees/tree_models.py +536 -0
  46. polyergalio/transforms/__init__.py +0 -0
  47. polyergalio/transforms/calibrations.py +654 -0
  48. polyergalio/transforms/projections.py +99 -0
  49. polyergalio/types.py +245 -0
  50. polyergalio/utilities.py +162 -0
  51. polyergalio/visuals/__init__.py +3 -0
  52. polyergalio/visuals/animation_utils.py +55 -0
  53. polyergalio/visuals/cluster_visuals.py +308 -0
  54. polyergalio/visuals/nnet_visuals.py +128 -0
  55. polyergalio/visuals/supervised_visuals.py +323 -0
  56. polyergalio-0.1.0.dist-info/METADATA +157 -0
  57. polyergalio-0.1.0.dist-info/RECORD +60 -0
  58. polyergalio-0.1.0.dist-info/WHEEL +5 -0
  59. polyergalio-0.1.0.dist-info/licenses/LICENSE +7 -0
  60. polyergalio-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,12 @@
1
+ """
2
+ polyergalio: implemented machine learning methods -- encoding,
3
+ supervised learning, and clustering -- built on NumPy and SciPy.
4
+ """
5
+ import logging
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ log = logging.getLogger(__name__)
10
+ log.addHandler(logging.NullHandler())
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,112 @@
1
+ import numpy as np
2
+ from numpy.typing import NDArray
3
+ from scipy.spatial.distance import cdist, pdist
4
+ from polyergalio.utilities import preformat_expected_shapes
5
+ from polyergalio.models.constants import EPSILON
6
+
7
+
8
+ ########### DISTANCES ###########
9
+ def manhattan_distance(x: NDArray, y: NDArray, summed: bool = True) -> NDArray:
10
+ """
11
+ aka Chebyshev
12
+ :param x:
13
+ :param y:
14
+ :return:
15
+ """
16
+ x, y = preformat_expected_shapes(x, y)
17
+ # sum(np.abs(x-y), axis=?)
18
+ if summed:
19
+ return np.sum(np.abs(x - y), axis=-1)
20
+ else:
21
+ return np.abs(x - y)
22
+
23
+
24
+ def grid_manhattan_distance(row_a, col_a, row_b, col_b):
25
+ return abs(row_a - row_b) + abs(col_a - col_b)
26
+
27
+
28
+ def euclidian_distance(x: NDArray, y: NDArray) -> NDArray:
29
+ """
30
+ for size (5000, 100), per sample, np.sqrtsum method took 1.07ms
31
+ linalg.norm took 1.21ms
32
+ numpy 1.20.3
33
+ :param x:
34
+ :param y:
35
+ :return:
36
+ """
37
+ x, y = preformat_expected_shapes(x, y)
38
+ return np.sqrt(np.sum((x - y) ** 2, axis=-1)) # + EPSILON
39
+ # return cdist(x, y, metric='euclidean')
40
+
41
+
42
+ def norm_euclidian_distance(x: NDArray, y: NDArray) -> NDArray:
43
+ # numerator = (np.linalg.norm((x - np.mean(x)) - (y - np.mean(y))) ** 2)
44
+ # denom = (np.linalg.norm(x - np.mean(x)) ** 2 + np.linalg.norm(y - np.mean(y)) ** 2)
45
+ # return 0.5 * (numerator / denom)
46
+
47
+ vx = np.var(x, axis=-1)
48
+ vy = np.var(y, axis=-1)
49
+ return 0.5 * (vx / (vy + vx))
50
+
51
+
52
+ def mahalonobis_distance(x: NDArray, y: NDArray) -> NDArray:
53
+ """
54
+
55
+ multivariate equivilant of euclidian distance, comparing point to distribution
56
+ :param x:
57
+ :param y:
58
+ :return:
59
+ """
60
+ stacked_vecs = np.vstack([x, y])
61
+ covariance = np.cov(stacked_vecs.T)
62
+ inv_covariance = np.linalg.inv(covariance)
63
+ delta = x - y
64
+ # may have to fix reshape for multi-dim...
65
+ return np.sqrt(np.einsum('nj,jk,nk->n', delta, inv_covariance, delta)).reshape(x.shape[0], -1)
66
+
67
+
68
+ def hamming_distance(x: NDArray, y: NDArray) -> NDArray:
69
+ """
70
+ :param x:
71
+ :param y:
72
+ :return:
73
+ """
74
+ # axis?
75
+ return np.count_nonzero(x != y, axis=-1)
76
+
77
+
78
+ def cosine_distance(x: NDArray, y: NDArray) -> NDArray:
79
+ """
80
+ inverse cosine similarity
81
+ :param x:
82
+ :param y:
83
+ :return:
84
+ """
85
+ x, y = preformat_expected_shapes(x, y)
86
+ return 1 - cosine_similarity(x, y)
87
+
88
+
89
+ # +---------Similarity---------
90
+ def cosine_similarity(x: NDArray, y: NDArray) -> NDArray:
91
+ """
92
+ requires numpy v1.7+ for 'where' in np.divide()
93
+ numpy array is [samples, dimension_1, dimension_2, ...]
94
+ we will compute cosine distance per samples dimension
95
+ input array is [500, 5] - output will be [500, 1]
96
+ :param x:
97
+ :param y:
98
+ :return:
99
+ """
100
+ dot = np.sum(x * y, axis=-1)
101
+ x_norm = np.sqrt(np.sum(x * x, axis=-1))
102
+ y_norm = np.sqrt(np.sum(y * y, axis=-1))
103
+
104
+ denom = x_norm * y_norm
105
+ return dot / np.maximum(denom, EPSILON)
106
+
107
+
108
+ def jaccard_similarity(x: NDArray, y: NDArray) -> NDArray:
109
+ """aka Jaccard Index
110
+ for use in boolean / on-hot / binary array
111
+ """
112
+ return np.bitwise_and(x, y).sum(axis=-1) / np.bitwise_or(x, y).sum(axis=-1)
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ log = logging.getLogger(__name__)
@@ -0,0 +1,484 @@
1
+ """
2
+ Signal (audio) preprocessing / encoders ------------------------
3
+ Waveform to windowed spectral features and back again.
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from polyergalio.encoders.encoders import Processor
11
+ from polyergalio.models.constants import EPSILON
12
+ from polyergalio.utilities import rolling_windows_nd, standardize_data
13
+ from numpy.typing import NDArray
14
+ from scipy.io import wavfile
15
+ from scipy.signal import decimate
16
+
17
+
18
+ def downsample_sequence(
19
+ data: NDArray, factor: int, mask: Optional[NDArray] = None
20
+ ) -> tuple[NDArray, Optional[NDArray]]:
21
+ """
22
+ Downsample one step along the sequence axis, with anti-aliasing.
23
+
24
+ Parameters
25
+ ----------
26
+ data : (batch, sequence, hidden)
27
+ factor : integer decimation factor. scipy recommends chaining factors of
28
+ 13 or less rather than using one large factor.
29
+ mask : optional (batch, sequence) or (batch, sequence, 1), 1 for real
30
+ content, 0 for padding. A downsampled position is marked real if any
31
+ of the original positions it covers were real -- with right-padding,
32
+ that is exactly the positions before the true length.
33
+
34
+ Returns
35
+ -------
36
+ downsampled data, and the corresponding downsampled mask (or None)
37
+ """
38
+ assert data.ndim == 3, f"expected (batch, sequence, hidden), got {data.shape}"
39
+ assert factor >= 1, "factor must be a positive integer"
40
+
41
+ if factor == 1:
42
+ return data, mask
43
+
44
+ downsampled = decimate(data, factor, axis=1, zero_phase=True)
45
+
46
+ if mask is None:
47
+ return downsampled, None
48
+
49
+ positions = mask[..., 0] if mask.ndim == 3 else mask
50
+ sequence = positions.shape[1]
51
+ pad = (-sequence) % factor
52
+ if pad:
53
+ positions = np.pad(positions, ((0, 0), (0, pad)))
54
+ blocks = positions.reshape(positions.shape[0], -1, factor)
55
+ downsampled_mask = (blocks.sum(axis=-1) > 0).astype(mask.dtype)
56
+ downsampled_mask = downsampled_mask[:, :downsampled.shape[1]]
57
+
58
+ return downsampled, downsampled_mask
59
+
60
+
61
+ def progressive_downsample(
62
+ data: NDArray, factors: list[int], mask: Optional[NDArray] = None
63
+ ) -> list[tuple[NDArray, Optional[NDArray]]]:
64
+ """
65
+ BUILD a resolution pyramid by chaining downsample_sequence
66
+
67
+ Each level downsamples the PREVIOUS level's output
68
+
69
+ Parameters
70
+ ----------
71
+ data : (batch, sequence, hidden)
72
+ factors : per-level downsampling factor, e.g. [2, 2, 2] for a
73
+ 1x, 1/2x, 1/4x, 1/8x pyramid
74
+ mask : optional (batch, sequence) or (batch, sequence, 1)
75
+
76
+ Returns
77
+ -------
78
+ list of (data, mask) pairs, from index 0 (full resolution) through the
79
+ last entry (most downsampled)
80
+ """
81
+ levels = [(data, mask)]
82
+ current_data, current_mask = data, mask
83
+ for factor in factors:
84
+ current_data, current_mask = downsample_sequence(current_data, factor, current_mask)
85
+ levels.append((current_data, current_mask))
86
+ return levels
87
+
88
+
89
+ def read_wav(
90
+ file_path: str,
91
+ channel: Optional[int] = 0,
92
+ standardize: bool = True
93
+ ) -> tuple[int, NDArray]:
94
+ """
95
+ Read a wav file and pull out one channel.
96
+
97
+ Parameters
98
+ ----------
99
+ file_path : path to the wav file
100
+ channel : channel index, or None to keep every channel
101
+ standardize : zero mean and unit variance the samples
102
+
103
+ Returns
104
+ -------
105
+ (sample_rate, waveform)
106
+ """
107
+ sample_rate, waveform = wavfile.read(file_path)
108
+
109
+ if channel is not None and waveform.ndim > 1:
110
+ waveform = waveform[:, channel]
111
+
112
+ waveform = waveform.astype(np.float64)
113
+ if standardize:
114
+ waveform = standardize_data(waveform, axis=0)
115
+
116
+ return sample_rate, waveform
117
+
118
+
119
+ def window_size_from_ms(sample_rate: int, window_ms: float) -> int:
120
+ """number of samples spanned by a window of window_ms milliseconds"""
121
+ return int(window_ms * sample_rate / 1000)
122
+
123
+
124
+ def build_windows(
125
+ waveform: NDArray, window_size: int, num_overlap: int = 0
126
+ ) -> NDArray:
127
+ """
128
+ Cut the waveform into overlapping windows, shaped (num_frames, window_size).
129
+ Every downstream stage works from these windowed timesteps, so the spectrogram and the
130
+ time domain windows always describe the same segments of signal.
131
+ """
132
+ if waveform.ndim == 2: # if we have 2 dimensions (batch, amplitude)
133
+ axis = 1
134
+ elif waveform.ndim == 1:
135
+ axis = 0
136
+ return rolling_windows_nd(
137
+ data=waveform, window_size=window_size, num_overlap=num_overlap, axis=axis
138
+ )
139
+
140
+
141
+ def windowed_spectrum(
142
+ windows: NDArray,
143
+ window_kernel: Optional[NDArray] = None
144
+ ) -> NDArray:
145
+ """
146
+ Real FFT of each window, tapered first to stop the window edges ringing.
147
+
148
+ Returns the complex spectrum, shaped (..., window_size // 2 + 1)
149
+ """
150
+ if window_kernel is None:
151
+ window_kernel = np.blackman(windows.shape[-1])
152
+
153
+ return np.fft.rfft(windows * window_kernel, axis=-1)
154
+
155
+
156
+ def _paired_bins(num_frequencies: int, window_size: Optional[int]) -> slice:
157
+ """
158
+ which rfft bins stand for a conjugate pair, and so carry double the energy
159
+ of a two sided spectrum
160
+ """
161
+ if window_size is None:
162
+ window_size = 2 * (num_frequencies - 1)
163
+
164
+ nyquist_present = window_size % 2 == 0
165
+ return slice(1, -1 if nyquist_present else None)
166
+
167
+
168
+ def to_power(
169
+ spectrum: NDArray, window_size: Optional[int] = None, one_sided: bool = True
170
+ ) -> NDArray:
171
+ """
172
+ Power spectrum, |X|^2 for every bin.
173
+
174
+ Parameters
175
+ ----------
176
+ spectrum : complex rfft output
177
+ window_size : length of the window the spectrum came from. Defaults to the
178
+ even case, so pass it explicitly whenever the window length is odd.
179
+ one_sided : double the paired bins so the total matches the energy of the
180
+ full two sided spectrum
181
+ """
182
+ power = np.abs(spectrum) ** 2
183
+
184
+ if one_sided:
185
+ power[..., _paired_bins(spectrum.shape[-1], window_size)] *= 2
186
+
187
+ return power
188
+
189
+
190
+ def to_decibels(power: NDArray, epsilon: float = EPSILON) -> NDArray:
191
+ """decibels from a power spectrum, 10 * log_base_10, floored by epsilon"""
192
+ return 10 * np.log10(power + epsilon)
193
+
194
+
195
+ def from_decibels(decibels: NDArray, epsilon: float = EPSILON) -> NDArray:
196
+ """inverse of to_decibels"""
197
+ return np.maximum(10 ** (decibels / 10) - epsilon, 0.0)
198
+
199
+
200
+ def frequency_axis(window_size: int, sample_rate: int) -> NDArray:
201
+ """centre frequency in Hz of each rfft bin"""
202
+ return np.fft.rfftfreq(window_size, d=1.0 / sample_rate)
203
+
204
+
205
+ # ------------- windows back to a waveform --------------------
206
+ def overlap_add(
207
+ windows: NDArray, num_overlap: int = 0, window_kernel: Optional[NDArray] = None
208
+ ) -> NDArray:
209
+ """
210
+ Fold overlapping windows back into one signal, weighted so that the taper
211
+ applied on the way in is divided out on the way back.
212
+ """
213
+ num_windows, window_size = windows.shape
214
+ stride = window_size - num_overlap
215
+
216
+ if window_kernel is None:
217
+ window_kernel = np.ones(window_size)
218
+
219
+ length = stride * (num_windows - 1) + window_size
220
+ signal = np.zeros(length)
221
+ weight = np.zeros(length)
222
+
223
+ for i in range(num_windows):
224
+ start = i * stride
225
+ signal[start: start + window_size] += windows[i] * window_kernel
226
+ weight[start: start + window_size] += window_kernel ** 2
227
+
228
+ return signal / np.maximum(weight, EPSILON)
229
+
230
+
231
+ # ------------- plotting -------------------------------------
232
+ def plot_waveform(waveform: NDArray, sample_rate: Optional[int] = None) -> None:
233
+ """time domain view of one channel"""
234
+ xs = np.arange(waveform.shape[0])
235
+ label = "Sample"
236
+ if sample_rate is not None:
237
+ xs = xs / sample_rate
238
+ label = "Time (seconds)"
239
+
240
+ plt.figure(figsize=(10, 4))
241
+ plt.plot(xs, waveform)
242
+ plt.title("Audio waveform")
243
+ plt.xlabel(label)
244
+ plt.ylabel("Amplitude")
245
+ plt.axis("tight")
246
+ plt.show()
247
+
248
+
249
+ def plot_spectrogram(
250
+ decibels: NDArray, freqs: NDArray, sample_rate: Optional[int] = None
251
+ ) -> None:
252
+ """decibel spectrogram, frames on x and frequency on y"""
253
+ time_axis = np.arange(decibels.shape[0])
254
+ label = "Frame"
255
+
256
+ plt.figure(figsize=(15, 8))
257
+ plt.pcolormesh(time_axis, freqs, decibels.T, shading="auto")
258
+ plt.xlabel(label)
259
+ plt.ylabel("Frequency (Hz)")
260
+ plt.title("Spectrogram")
261
+ plt.colorbar(label="dB")
262
+ plt.show()
263
+
264
+
265
+ def plot_window_spectra(
266
+ spectrum: NDArray,
267
+ freqs: NDArray,
268
+ index: int = 0,
269
+ window_size: Optional[int] = None,
270
+ ) -> None:
271
+ """
272
+ Amplitude, power and decibel view of a single frame. Replaces the older
273
+ plot_fft_windows, which assumed three named axes and a fixed subplot grid.
274
+ """
275
+ magnitude = np.abs(spectrum[index])
276
+ power = to_power(spectrum[index: index + 1], window_size=window_size)[0]
277
+ decibels = to_decibels(power)
278
+
279
+ panels = (
280
+ ("Amplitude spectrum", magnitude, "amplitude", plt.plot),
281
+ ("Power spectrum", power, "power", plt.plot),
282
+ ("Decibel spectrum", decibels, "dB", plt.plot),
283
+ )
284
+
285
+ plt.figure(figsize=(14, 10))
286
+ for position, (title, values, ylabel, draw) in enumerate(panels, start=1):
287
+ plt.subplot(len(panels), 1, position)
288
+ draw(freqs, values)
289
+ plt.title(f"{title}, frame {index}")
290
+ plt.xlabel("Frequency (Hz)")
291
+ plt.ylabel(ylabel)
292
+ plt.grid(True)
293
+ plt.tight_layout()
294
+ plt.show()
295
+
296
+
297
+ # ------------------------------------------------------------------
298
+ class AudioProcessor(Processor):
299
+ """
300
+ Waveform to a windowed spectral representation.
301
+
302
+ variable_idx carries the channel to pull out of a multi channel file, so
303
+ it keeps the same meaning it has for the tabular encoders: which column of
304
+ the source the encoder is responsible for.
305
+ """
306
+
307
+ def __init__(
308
+ self,
309
+ sample_rate: int,
310
+ window_ms: float = 20.0,
311
+ overlap_ratio: float = 1 / 3,
312
+ use_decibels: bool = True,
313
+ target: str = "waveform",
314
+ variable_idx: int = 0,
315
+ ):
316
+ """
317
+ Parameters
318
+ ----------
319
+ sample_rate : samples per second of the source signal
320
+ window_ms : frame length in milliseconds
321
+ overlap_ratio : fraction of a frame shared with the next frame
322
+ use_decibels : encode to decibels rather than raw power
323
+ target : name of the signal being encoded
324
+ variable_idx : channel index within a multi channel source
325
+ """
326
+ super().__init__(target=target, variable_idx=variable_idx)
327
+ self.sample_rate = sample_rate
328
+ self.window_ms = window_ms
329
+ self.overlap_ratio = overlap_ratio
330
+ self.use_decibels = use_decibels
331
+
332
+ self.window_size: int = window_size_from_ms(sample_rate, window_ms)
333
+ self.num_overlap: int = int(self.window_size * overlap_ratio)
334
+ self.window_kernel: NDArray = np.blackman(self.window_size)
335
+ self.freqs: NDArray = frequency_axis(self.window_size, sample_rate)
336
+
337
+
338
+ def fit(self, values: NDArray) -> "AudioProcessor":
339
+ """record the observed range, which inverse() needs to undo scaling"""
340
+ self.obs_min_max = (float(np.min(values)), float(np.max(values)))
341
+ self._fitted = True
342
+ return self
343
+
344
+ def encode(self, values: NDArray) -> dict[str,NDArray]:
345
+ """
346
+ Waveform to (num_frames, num_frequencies). The complex spectrum is
347
+ cached on the way through so inverse() can reuse its phase.
348
+ """
349
+ windows = build_windows(values, self.window_size, self.num_overlap)
350
+ power = to_power(windows, window_size=self.window_size)
351
+
352
+ self.spectrum = windowed_spectrum(power, self.window_kernel)
353
+
354
+ if self.use_decibels:
355
+ return {"spectrum": to_decibels(self.spectrum), "amplitude": to_decibels(power)}
356
+
357
+ return {"spectrum": self.spectrum, "amplitude": power}
358
+
359
+
360
+ def fit_encode(self, values: NDArray) -> NDArray:
361
+ return self.fit(values).encode(values)
362
+
363
+ def inverse(self, values: NDArray) -> NDArray:
364
+ """
365
+ Spectral features back to a waveform.
366
+
367
+ Magnitude alone does not determine a signal, so the phase cached by the
368
+ last encode() is reused when its shape matches. Without that cache the
369
+ reconstruction is zero phase and only approximate.
370
+ """
371
+ power = from_decibels(values) if self.use_decibels else values
372
+
373
+ two_sided = power.copy()
374
+ two_sided[..., _paired_bins(power.shape[-1], self.window_size)] /= 2
375
+
376
+ magnitude = np.sqrt(two_sided)
377
+
378
+ if self.spectrum is not None and self.spectrum.shape == magnitude.shape:
379
+ phase = np.exp(1j * np.angle(self.spectrum))
380
+ else:
381
+ phase = 1.0
382
+
383
+ windows = np.fft.irfft(magnitude * phase, n=self.window_size, axis=-1)
384
+ return overlap_add(windows, self.num_overlap, self.window_kernel)
385
+
386
+ @property
387
+ def metadata(self) -> dict:
388
+ return {
389
+ "target": self.target,
390
+ "variable_idx": self.variable_idx,
391
+ "sample_rate": self.sample_rate,
392
+ "window_ms": self.window_ms,
393
+ "window_size": self.window_size,
394
+ "num_overlap": self.num_overlap,
395
+ "num_frequencies": self.spectrum.size,
396
+ "use_decibels": self.use_decibels,
397
+ }
398
+
399
+ def __str__(self):
400
+ units = "dB" if self.use_decibels else "power"
401
+ return (
402
+ f"AudioProcessor at {self.sample_rate} Hz, {self.window_size} sample "
403
+ f"windows overlapping {self.num_overlap}, encoding to {units}"
404
+ )
405
+
406
+
407
+ if __name__ == "__main__":
408
+ SAMPLE_RATE = 8000
409
+ DURATION_SECONDS = 1.0
410
+ WINDOW_MS = 20.0
411
+
412
+ # frequency, amplitude. All three land on bin centres at this window size,
413
+ # so the encoded peaks can be checked against the tones that made them.
414
+ TONES = ((250.0, 1.0), (1000.0, 0.5), (2500.0, 0.25))
415
+
416
+ seconds = np.arange(int(SAMPLE_RATE * DURATION_SECONDS)) / SAMPLE_RATE
417
+ waveform = sum(
418
+ amplitude * np.sin(2 * np.pi * frequency * seconds)
419
+ for frequency, amplitude in TONES
420
+ )
421
+
422
+ print("---- source signal ----")
423
+ print(f"{waveform.size} samples at {SAMPLE_RATE} Hz")
424
+ for frequency, amplitude in TONES:
425
+ print(f" tone {frequency:7.1f} Hz at amplitude {amplitude}")
426
+
427
+ # ---- the stages, one at a time ----
428
+ window_size = window_size_from_ms(SAMPLE_RATE, WINDOW_MS)
429
+ num_overlap = window_size // 3
430
+
431
+ windows = build_windows(waveform, window_size, num_overlap)
432
+ spectrum = windowed_spectrum(windows)
433
+ power = to_power(spectrum, window_size=window_size)
434
+ decibels = to_decibels(power)
435
+ freqs = frequency_axis(window_size, SAMPLE_RATE)
436
+
437
+ print("\n---- staged encoding ----")
438
+ print(f"window_size {window_size} samples ({WINDOW_MS} ms)")
439
+ print(f"num_overlap {num_overlap} samples")
440
+ print(f"bin width {freqs[1] - freqs[0]:.1f} Hz")
441
+ print(f"windows {windows.shape}")
442
+ print(f"spectrum {spectrum.shape} {spectrum.dtype}")
443
+ print(f"power, decibels {power.shape}, {decibels.shape}")
444
+
445
+ # each tone should show up as the loudest bin near its own frequency
446
+ mean_power = power.mean(axis=0)
447
+ print("\n---- recovered peaks ----")
448
+ for frequency, amplitude in TONES:
449
+ nearby = np.abs(freqs - frequency) <= 100.0
450
+ detected = freqs[nearby][np.argmax(mean_power[nearby])]
451
+ loudest = 10 * np.log10(mean_power[nearby].max() / mean_power.max())
452
+ print(
453
+ f" expected {frequency:7.1f} Hz -> found {detected:7.1f} Hz"
454
+ f" {loudest:+6.2f} dB relative to the strongest tone"
455
+ )
456
+
457
+ # ---- the same thing through the Processor interface ----
458
+ processor = AudioProcessor(
459
+ sample_rate=SAMPLE_RATE, window_ms=WINDOW_MS, overlap_ratio=1 / 3
460
+ )
461
+ features = processor.fit_encode(waveform)
462
+
463
+ print(f"\n---- {processor} ----")
464
+ print(f"features {features.shape}")
465
+ print(f"is_fitted {processor.is_fitted}")
466
+ for key, value in processor.metadata.items():
467
+ print(f" {key:16s} {value}")
468
+
469
+ reconstruction = processor.inverse(features)
470
+ overlap = min(reconstruction.size, waveform.size)
471
+ interior = slice(window_size, overlap - window_size)
472
+ print(f"\nreconstruction {reconstruction.shape}")
473
+ print(
474
+ "interior error "
475
+ f"{np.abs(reconstruction[interior] - waveform[interior]).max():.2e}"
476
+ )
477
+
478
+ # ---- plots ----
479
+ visible = slice(0, int(0.05 * SAMPLE_RATE))
480
+ plot_waveform(waveform[visible], SAMPLE_RATE)
481
+ plot_spectrogram(features, processor.freqs)
482
+ plot_window_spectra(
483
+ processor.spectrum, processor.freqs, index=10, window_size=window_size
484
+ )