Skip to content

Commit 2ffabcd

Browse files
committed
important refactoring changes to all models, adding clarity and some speed to them
1 parent 5d734c2 commit 2ffabcd

19 files changed

Lines changed: 1351 additions & 1133 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
.idea/**/dictionaries
1515
.idea/**/shelf
1616

17+
# Ignore all benchmarks
18+
benchmarks/
19+
1720
software/.vs/*
1821
workspace_dev/*
1922
workspace/*

MANIFEST.in

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
global-include *.pyx
2-
global-include *.pxd
2+
global-include *.pxd
3+
exclude benchmarks/*

README.md

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,35 @@ Install using pip:
2323

2424
pip install sms-tools
2525

26-
Binary packages are available for Linux, macOS (Intel & Apple Silicon) and Windows (64 bit) on all recent python versions.
27-
28-
To build and install the package locally you can use the python packaging tools:
29-
30-
pip install build
31-
python -m build
3226

27+
When installing via pip, the Cython extension is built automatically if a compatible compiler and Python environment are available. This provides significant speedups for core routines.
3328

34-
Running tests
35-
-------------
36-
37-
To run the unit test suite locally:
29+
pip install sms-tools
3830

39-
python3 -m venv .venv
40-
source .venv/bin/activate
41-
python -m pip install -e ".[test]"
42-
python -m pytest
31+
If you are developing locally or want to ensure the Cython extension is built, you can run:
4332

44-
To run a single test file:
33+
pip install sms-tools
34+
python setup.py build_ext --inplace
4535

46-
python -m pytest tests/test_errors.py
36+
If you encounter issues with the Cython extension, ensure you have Cython, setuptools, and a C compiler installed. The extension is optional; sms-tools will fall back to pure-Python routines if unavailable.
4737

48-
To run a single test function by name:
38+
You can verify which backend is active at runtime:
4939

50-
python -m pytest tests/test_errors.py -k wavread
40+
python - <<'PY'
41+
from smstools.models import utilFunctions as UF
42+
print("Using Cython backend:", UF.UF_C is not None)
43+
print("Backend module:", getattr(UF.UF_C, "__file__", None))
44+
PY
5145

52-
Main smoke test files:
46+
Binary packages are available for Linux, macOS (Intel & Apple Silicon) and Windows (64 bit) on all recent python versions.
5347

54-
tests/test_models_smoke.py
55-
tests/test_transformations_smoke.py
48+
For details about automatic Cython acceleration and fallback behavior, see the
49+
"Cython backend" section below.
5650

57-
Run only smoke tests:
51+
To build and install the package locally you can use the python packaging tools:
5852

59-
python -m pytest -k smoke
53+
pip install build
54+
python -m build
6055

6156

6257
Cython backend
@@ -75,15 +70,19 @@ You can verify which backend is active at runtime:
7570
print("Backend module:", getattr(UF.UF_C, "__file__", None))
7671
PY
7772

78-
Test case summary:
73+
Testing
74+
-------
75+
76+
To install test dependencies and run the test suite, use:
77+
78+
pip install .[test]
79+
pytest
80+
81+
You can run a specific test file with:
7982

80-
* `tests/test_api_contracts.py`: API/signature and output-shape contract checks for core model entry points.
81-
* `tests/test_errors.py`: error-handling contracts (invalid parameters and invalid I/O paths).
82-
* `tests/test_models_smoke.py`: fast smoke coverage for all analysis/synthesis model modules.
83-
* `tests/test_transformations_smoke.py`: fast smoke coverage for all transformation modules.
84-
* `tests/test_models_ground_truth.py`: algorithmic/ground-truth model tests on synthetic signals (frequency accuracy, additivity, and quality invariants).
85-
* `tests/test_transformations_ground_truth.py`: algorithmic/ground-truth transformation tests (scaling/morphing identity behavior and expected interpolation/attenuation trends).
83+
pytest -v tests/test_cython_vs_python.py
8684

85+
This will execute all tests and print detailed output.
8786

8887
Jupyter Notebooks
8988
-------

smstools/models/dftModel.py

Lines changed: 97 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -8,120 +8,129 @@
88
import numpy as np
99
from scipy.fft import irfft, rfft
1010
from smstools.models import utilFunctions as UF
11-
tol = 1e-14 # threshold used to compute phase
12-
_EPS = np.finfo(float).eps
1311

12+
tol: float = 1e-14 # threshold used to compute phase
13+
_EPS: float = np.finfo(float).eps
1414

15-
def _validate_window_fft_size(w, N):
15+
def _validate_window_fft_size(w: np.ndarray, N: int) -> None:
16+
"""
17+
Validate window and FFT size for DFT operations.
18+
19+
Args:
20+
w: Analysis window array.
21+
N: FFT size.
22+
23+
Raises:
24+
ValueError: If N is not a power of 2 or w is larger than N.
25+
"""
1626
if not UF.isPower2(N):
17-
raise ValueError("FFT size (N) is not a power of 2")
27+
raise ValueError(f"FFT size (N={N}) is not a power of 2. Provided window size: {w.size}.")
1828
if w.size > N:
19-
raise ValueError("Window size (M) is bigger than FFT size")
20-
29+
raise ValueError(f"Window size (M={w.size}) is bigger than FFT size (N={N}).")
2130

22-
def _positive_spectrum_from_fft(X, hN):
31+
def _positive_spectrum_from_fft(X: np.ndarray, hN: int) -> np.ndarray:
2332
"""
24-
The function `_positive_spectrum_from_fft` calculates the magnitude spectrum in decibels from the
25-
FFT output.
33+
Calculate magnitude spectrum in decibels from FFT output.
34+
35+
Args:
36+
X: FFT output array.
37+
hN: Size of positive spectrum.
38+
39+
Returns:
40+
Magnitude spectrum in dB.
2641
"""
2742
absX = np.abs(X[:hN])
28-
np.maximum(absX, _EPS, out=absX) # avoid log of zero by replacing small values with _EPS
43+
np.maximum(absX, _EPS, out=absX)
2944
return 20 * np.log10(absX)
3045

46+
def _build_positive_spectrum(mX: np.ndarray, pX: np.ndarray) -> np.ndarray:
47+
"""
48+
Build positive spectrum from magnitude and phase.
3149
32-
def _build_positive_spectrum(mX, pX):
50+
Args:
51+
mX: Magnitude spectrum (dB).
52+
pX: Phase spectrum (radians).
53+
54+
Returns:
55+
Complex positive spectrum.
56+
"""
3357
pos_mag = 10 ** (mX / 20)
3458
return pos_mag * np.exp(1j * pX)
3559

36-
37-
def dftModel(x, w, N):
38-
"""
39-
Analysis/synthesis of a signal using the discrete Fourier transform
40-
x: input signal, w: analysis window, N: FFT size
41-
returns y: output signal
60+
def dftAnal(x: np.ndarray, w: np.ndarray, N: int) -> tuple[np.ndarray, np.ndarray]:
4261
"""
62+
Analysis of a signal using the discrete Fourier transform.
4363
44-
_validate_window_fft_size(w, N)
45-
46-
if not np.any(x): # if input array is zeros return empty output
47-
return np.zeros(x.size)
48-
49-
hN = (N // 2) + 1 # size of positive spectrum, it includes sample 0
50-
hM1 = (w.size + 1) // 2 # half analysis window size by rounding
51-
hM2 = w.size // 2 # half analysis window size by floor
52-
fftbuffer = np.zeros(N) # initialize buffer for FFT
53-
y = np.zeros(x.size) # initialize output array
64+
Args:
65+
x: Input signal.
66+
w: Analysis window.
67+
N: FFT size.
5468
55-
# ----analysis--------
56-
xw = x * w # window the input sound
57-
fftbuffer[:hM1] = xw[hM2:] # zero-phase window in fftbuffer
58-
fftbuffer[-hM2:] = xw[:hM2]
59-
Xh = rfft(fftbuffer, n=N) # compute positive spectrum (real FFT)
60-
mX = _positive_spectrum_from_fft(Xh, hN) # magnitude spectrum in dB
61-
pX = np.unwrap(np.angle(Xh)) # unwrapped phase spectrum of positive frequencies
62-
63-
# -----synthesis-----
64-
Yh = _build_positive_spectrum(mX, pX)
65-
fftbuffer = irfft(Yh, n=N) # compute inverse real FFT
66-
y[:hM2] = fftbuffer[-hM2:] # undo zero-phase window
67-
y[hM2:] = fftbuffer[:hM1]
68-
return y
69-
70-
71-
def dftAnal(x, w, N):
72-
"""
73-
Analysis of a signal using the discrete Fourier transform
74-
x: input signal, w: analysis window, N: FFT size
75-
returns mX, pX: magnitude and phase spectrum
76-
77-
The analysis window is internally normalized by sum(w), so the resulting
78-
spectra correspond to x * (w / sum(w)).
69+
Returns:
70+
mX: Magnitude spectrum (dB).
71+
pX: Phase spectrum (radians).
7972
"""
80-
8173
_validate_window_fft_size(w, N)
82-
83-
hN = (N // 2) + 1 # size of positive spectrum, it includes sample 0
84-
hM1 = (w.size + 1) // 2 # half analysis window size by rounding
85-
hM2 = w.size // 2 # half analysis window size by floor
86-
fftbuffer = np.zeros(N) # initialize buffer for FFT
87-
w = w / np.sum(w) # normalize analysis window
88-
xw = x * w # window the input sound
89-
fftbuffer[:hM1] = xw[hM2:] # zero-phase window in fftbuffer
74+
hN = (N // 2) + 1
75+
hM1 = (w.size + 1) // 2
76+
hM2 = w.size // 2
77+
fftbuffer = np.zeros(N)
78+
w = w / np.sum(w)
79+
xw = x * w
80+
fftbuffer[:hM1] = xw[hM2:]
9081
fftbuffer[-hM2:] = xw[:hM2]
91-
Xh = rfft(fftbuffer, n=N) # compute positive spectrum (real FFT)
92-
mX = _positive_spectrum_from_fft(Xh, hN) # magnitude spectrum in dB
93-
82+
Xh = rfft(fftbuffer, n=N)
83+
mX = _positive_spectrum_from_fft(Xh, hN)
9484
Xh = Xh.copy()
95-
Xh.real[
96-
np.abs(Xh.real) < tol
97-
] = 0.0 # for phase calculation set to 0 the small values
98-
Xh.imag[
99-
np.abs(Xh.imag) < tol
100-
] = 0.0 # for phase calculation set to 0 the small values
101-
pX = np.unwrap(np.angle(Xh)) # unwrapped phase spectrum of positive frequencies
85+
Xh.real[np.abs(Xh.real) < tol] = 0.0
86+
Xh.imag[np.abs(Xh.imag) < tol] = 0.0
87+
pX = np.unwrap(np.angle(Xh))
10288
return mX, pX
10389

104-
105-
def dftSynth(mX, pX, M):
90+
def dftSynth(mX: np.ndarray, pX: np.ndarray, M: int) -> np.ndarray:
10691
"""
107-
Synthesis of a signal using the discrete Fourier transform
108-
mX: magnitude spectrum, pX: phase spectrum, M: window size
109-
returns y: output signal
92+
Synthesis of a signal using the discrete Fourier transform.
11093
111-
If mX/pX come from dftAnal(), the output corresponds to the normalized
112-
windowed signal used there (x * (w / sum(w))).
113-
"""
114-
115-
hN = mX.size # size of positive spectrum, it includes sample 0
116-
N = (hN - 1) * 2 # FFT size
117-
if not UF.isPower2(N): # raise error if N not a power of two, thus mX is wrong
118-
raise ValueError("size of mX is not (N/2)+1")
94+
Args:
95+
mX: Magnitude spectrum (dB).
96+
pX: Phase spectrum (radians).
97+
M: Window size.
11998
120-
hM1 = (M + 1) // 2 # half analysis window size by rounding
121-
hM2 = M // 2 # half analysis window size by floor
122-
y = np.zeros(M) # initialize output array
99+
Returns:
100+
y: Output signal (length M).
101+
"""
102+
hN = mX.size
103+
N = (hN - 1) * 2
104+
if not UF.isPower2(N):
105+
raise ValueError(f"size of mX ({mX.size}) is not (N/2)+1 for N={N}. Check input spectrum size.")
106+
hM1 = (M + 1) // 2
107+
hM2 = M // 2
108+
y = np.zeros(M)
123109
Yh = _build_positive_spectrum(mX, pX)
124-
fftbuffer = irfft(Yh, n=N) # compute inverse real FFT
125-
y[:hM2] = fftbuffer[-hM2:] # undo zero-phase window
110+
fftbuffer = irfft(Yh, n=N)
111+
y[:hM2] = fftbuffer[-hM2:]
126112
y[hM2:] = fftbuffer[:hM1]
127113
return y
114+
115+
def dftModel(x: np.ndarray, w: np.ndarray, N: int) -> np.ndarray:
116+
"""
117+
Analysis/synthesis of a signal using the discrete Fourier transform.
118+
119+
Args:
120+
x: Input signal.
121+
w: Analysis window.
122+
N: FFT size.
123+
124+
Returns:
125+
y: Output signal (same shape as x).
126+
"""
127+
# Analysis
128+
mX, pX = dftAnal(x, w, N)
129+
# Synthesis
130+
y = dftSynth(mX, pX, w.size)
131+
# Ensure output length matches input
132+
if len(y) > len(x):
133+
y = y[:len(x)]
134+
elif len(y) < len(x):
135+
y = np.pad(y, (0, len(x) - len(y)))
136+
return y

0 commit comments

Comments
 (0)