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.
- polyergalio/__init__.py +12 -0
- polyergalio/distances.py +112 -0
- polyergalio/encoders/__init__.py +3 -0
- polyergalio/encoders/audio_encoders.py +484 -0
- polyergalio/encoders/categorical_encoders.py +201 -0
- polyergalio/encoders/chronologic_encoders.py +213 -0
- polyergalio/encoders/encoder_constants.py +23 -0
- polyergalio/encoders/encoder_utils.py +294 -0
- polyergalio/encoders/encoders.py +62 -0
- polyergalio/encoders/numeric_encoders.py +235 -0
- polyergalio/encoders/pipeline.py +671 -0
- polyergalio/encoders/tokenizer.py +622 -0
- polyergalio/encoders/tokenizer_fitting.py +167 -0
- polyergalio/generators/__init__.py +4 -0
- polyergalio/generators/data_generators.py +1012 -0
- polyergalio/generators/periodic_signal_gen.py +59 -0
- polyergalio/models/__init__.py +3 -0
- polyergalio/models/activations.py +200 -0
- polyergalio/models/clustering/__init__.py +3 -0
- polyergalio/models/clustering/centroid_network.py +599 -0
- polyergalio/models/clustering/cluster_metrics.py +340 -0
- polyergalio/models/clustering/freeplsom_clustering.py +572 -0
- polyergalio/models/clustering/gplsom_clustering.py +510 -0
- polyergalio/models/clustering/plsom_clustering.py +685 -0
- polyergalio/models/clustering/plsom_utils.py +245 -0
- polyergalio/models/constants.py +75 -0
- polyergalio/models/embedding/__init__.py +0 -0
- polyergalio/models/embedding/embedding.py +128 -0
- polyergalio/models/embedding/positional.py +323 -0
- polyergalio/models/layers/__init__.py +1 -0
- polyergalio/models/layers/basal_layers.py +744 -0
- polyergalio/models/layers/decision_layers.py +495 -0
- polyergalio/models/layers/fft_layers.py +370 -0
- polyergalio/models/layers/mixture_layers.py +647 -0
- polyergalio/models/layers/operator_layers.py +612 -0
- polyergalio/models/layers/spectre_layers.py +1344 -0
- polyergalio/models/layers/wavelet_layers.py +453 -0
- polyergalio/models/model_loss.py +395 -0
- polyergalio/models/neural_network.py +724 -0
- polyergalio/models/optimizers.py +142 -0
- polyergalio/models/supervised/__init__.py +3 -0
- polyergalio/models/supervised/relative_weights.py +196 -0
- polyergalio/models/supervised/scg_regression.py +563 -0
- polyergalio/models/supervised/trees/__init__.py +0 -0
- polyergalio/models/supervised/trees/tree_models.py +536 -0
- polyergalio/transforms/__init__.py +0 -0
- polyergalio/transforms/calibrations.py +654 -0
- polyergalio/transforms/projections.py +99 -0
- polyergalio/types.py +245 -0
- polyergalio/utilities.py +162 -0
- polyergalio/visuals/__init__.py +3 -0
- polyergalio/visuals/animation_utils.py +55 -0
- polyergalio/visuals/cluster_visuals.py +308 -0
- polyergalio/visuals/nnet_visuals.py +128 -0
- polyergalio/visuals/supervised_visuals.py +323 -0
- polyergalio-0.1.0.dist-info/METADATA +157 -0
- polyergalio-0.1.0.dist-info/RECORD +60 -0
- polyergalio-0.1.0.dist-info/WHEEL +5 -0
- polyergalio-0.1.0.dist-info/licenses/LICENSE +7 -0
- polyergalio-0.1.0.dist-info/top_level.txt +1 -0
polyergalio/__init__.py
ADDED
|
@@ -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__"]
|
polyergalio/distances.py
ADDED
|
@@ -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,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
|
+
)
|