cicuetea 1.0.0__tar.gz
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.
- cicuetea-1.0.0/.gitignore +64 -0
- cicuetea-1.0.0/PKG-INFO +52 -0
- cicuetea-1.0.0/README.md +28 -0
- cicuetea-1.0.0/examples/Demo1.py +44 -0
- cicuetea-1.0.0/examples/Demo2.py +85 -0
- cicuetea-1.0.0/examples/Demo3.py +95 -0
- cicuetea-1.0.0/examples/psychoacoustics.py +104 -0
- cicuetea-1.0.0/pyproject.toml +36 -0
- cicuetea-1.0.0/src/cicuetea/__init__.py +15 -0
- cicuetea-1.0.0/src/cicuetea/nsgf_cqt.py +265 -0
- cicuetea-1.0.0/src/cicuetea/slicing.py +85 -0
- cicuetea-1.0.0/tests/test_roundtrip.py +51 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
._*
|
|
4
|
+
|
|
5
|
+
# Build directories (out-of-source CMake: build/, build2/, xcodeBuild/, ...)
|
|
6
|
+
build*/
|
|
7
|
+
*Build/
|
|
8
|
+
cmake-build-*/
|
|
9
|
+
|
|
10
|
+
# In-source CMake/CTest artifacts (safety net if configured at the root)
|
|
11
|
+
CMakeCache.txt
|
|
12
|
+
CMakeFiles/
|
|
13
|
+
CMakeScripts/
|
|
14
|
+
cmake_install.cmake
|
|
15
|
+
CTestTestfile.cmake
|
|
16
|
+
install_manifest.txt
|
|
17
|
+
compile_commands.json
|
|
18
|
+
CMakeLists.txt.user
|
|
19
|
+
Testing/
|
|
20
|
+
Makefile
|
|
21
|
+
_deps/
|
|
22
|
+
|
|
23
|
+
# Compiled artifacts
|
|
24
|
+
*.o
|
|
25
|
+
*.obj
|
|
26
|
+
*.a
|
|
27
|
+
*.lib
|
|
28
|
+
*.so
|
|
29
|
+
*.dylib
|
|
30
|
+
*.dll
|
|
31
|
+
*.d
|
|
32
|
+
*.gch
|
|
33
|
+
*.pch
|
|
34
|
+
*.exe
|
|
35
|
+
*.out
|
|
36
|
+
*.app
|
|
37
|
+
|
|
38
|
+
# Doxygen output (docs/Doxyfile is tracked; its output is not)
|
|
39
|
+
docs/html/
|
|
40
|
+
docs/latex/
|
|
41
|
+
|
|
42
|
+
# Editors / tools
|
|
43
|
+
.vscode/
|
|
44
|
+
.history/
|
|
45
|
+
|
|
46
|
+
# MATLAB autosaves and figure files
|
|
47
|
+
*.asv
|
|
48
|
+
*.m~
|
|
49
|
+
*.autosave
|
|
50
|
+
*.fig
|
|
51
|
+
|
|
52
|
+
# Python
|
|
53
|
+
__pycache__/
|
|
54
|
+
*.py[cod]
|
|
55
|
+
.venv/
|
|
56
|
+
venv/
|
|
57
|
+
.pytest_cache/
|
|
58
|
+
.ipynb_checkpoints/
|
|
59
|
+
dist/
|
|
60
|
+
*.egg-info/
|
|
61
|
+
|
|
62
|
+
# Local scratch (not part of the release)
|
|
63
|
+
BenchTests/
|
|
64
|
+
ReleaseChecklist.md
|
cicuetea-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cicuetea
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Real-time invertible Constant-Q Transform (CQT) engine based on nonstationary Gabor frames — Python reference implementation
|
|
5
|
+
Project-URL: Homepage, https://github.com/jdsierral/CiCueTea
|
|
6
|
+
Project-URL: Documentation, https://jdsierral.github.io/CiCueTea/
|
|
7
|
+
Project-URL: Repository, https://github.com/jdsierral/CiCueTea
|
|
8
|
+
Author: Juan Sierra
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: audio,constant-q transform,cqt,dsp,nsgf,spectral processing
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Requires-Dist: scipy
|
|
19
|
+
Provides-Extra: demo
|
|
20
|
+
Requires-Dist: matplotlib; extra == 'demo'
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest; extra == 'test'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# cicuetea
|
|
26
|
+
|
|
27
|
+
Python reference implementation of **CiCueTea**, a real-time, invertible
|
|
28
|
+
Constant-Q Transform (CQT) engine based on nonstationary Gabor frames (NSGF).
|
|
29
|
+
|
|
30
|
+
This package is an offline, NumPy/SciPy reference — dense and sparse forward/inverse
|
|
31
|
+
transforms, sparse-to-dense rasterization, and the block slicing/splicing utilities used to
|
|
32
|
+
build a streaming pipeline on top of it. The real-time, allocation-free implementation lives
|
|
33
|
+
in the C++ library this package accompanies.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import numpy as np
|
|
37
|
+
from cicuetea import NsgfCQT
|
|
38
|
+
|
|
39
|
+
fs, n_samples, frac, f_min, f_max = 48000, 2**16, 1 / 48, 100, 10000
|
|
40
|
+
x = np.random.randn(n_samples)
|
|
41
|
+
|
|
42
|
+
cqt = NsgfCQT("dense", fs, n_samples, frac, f_min, f_max)
|
|
43
|
+
X = cqt.forward(x)
|
|
44
|
+
y = cqt.inverse(X)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Full documentation, the C++ engine, and the MATLAB reference implementation live at
|
|
48
|
+
<https://github.com/jdsierral/CiCueTea>.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT — see [LICENSE](https://github.com/jdsierral/CiCueTea/blob/main/LICENSE).
|
cicuetea-1.0.0/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# cicuetea
|
|
2
|
+
|
|
3
|
+
Python reference implementation of **CiCueTea**, a real-time, invertible
|
|
4
|
+
Constant-Q Transform (CQT) engine based on nonstationary Gabor frames (NSGF).
|
|
5
|
+
|
|
6
|
+
This package is an offline, NumPy/SciPy reference — dense and sparse forward/inverse
|
|
7
|
+
transforms, sparse-to-dense rasterization, and the block slicing/splicing utilities used to
|
|
8
|
+
build a streaming pipeline on top of it. The real-time, allocation-free implementation lives
|
|
9
|
+
in the C++ library this package accompanies.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import numpy as np
|
|
13
|
+
from cicuetea import NsgfCQT
|
|
14
|
+
|
|
15
|
+
fs, n_samples, frac, f_min, f_max = 48000, 2**16, 1 / 48, 100, 10000
|
|
16
|
+
x = np.random.randn(n_samples)
|
|
17
|
+
|
|
18
|
+
cqt = NsgfCQT("dense", fs, n_samples, frac, f_min, f_max)
|
|
19
|
+
X = cqt.forward(x)
|
|
20
|
+
y = cqt.inverse(X)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Full documentation, the C++ engine, and the MATLAB reference implementation live at
|
|
24
|
+
<https://github.com/jdsierral/CiCueTea>.
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see [LICENSE](https://github.com/jdsierral/CiCueTea/blob/main/LICENSE).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy.signal as sg
|
|
4
|
+
import cicuetea.nsgf_cqt as NsgfCQT
|
|
5
|
+
from cicuetea.slicing import *
|
|
6
|
+
from psychoacoustics import *
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dB(X):
|
|
10
|
+
return 20.0 * np.log10(np.abs(X) + 1e-10)
|
|
11
|
+
|
|
12
|
+
def rms(x):
|
|
13
|
+
return np.sqrt(np.mean(np.abs(x)**2.0))
|
|
14
|
+
|
|
15
|
+
fs = 48000
|
|
16
|
+
nSamps = 2**18
|
|
17
|
+
frac = 1.0
|
|
18
|
+
fMin = 100
|
|
19
|
+
fMax = 10000
|
|
20
|
+
|
|
21
|
+
t = np.arange(nSamps) / fs
|
|
22
|
+
x = sg.chirp(t, fMin, t[-1], fMax, 'logarithmic')
|
|
23
|
+
w = sg.windows.kaiser(nSamps, 20)
|
|
24
|
+
x *= w
|
|
25
|
+
|
|
26
|
+
sERB = NsgfCQT.NsgfVQT('dense', fs, nSamps, freq2erb, 1/2)
|
|
27
|
+
sCQT = NsgfCQT.NsgfVQT('dense', fs, nSamps, np.log2, 1/12)
|
|
28
|
+
|
|
29
|
+
Xerb = sERB.forward(x)
|
|
30
|
+
Xcqt = sCQT.forward(x)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
plt.figure(1)
|
|
34
|
+
plt.clf()
|
|
35
|
+
plt.subplot(2, 1, 1)
|
|
36
|
+
plt.pcolormesh(sERB.time_axis, sERB.band_axis, dB(Xerb).T)
|
|
37
|
+
plt.clim(-100, 0)
|
|
38
|
+
|
|
39
|
+
plt.subplot(2, 1, 2)
|
|
40
|
+
plt.pcolormesh(sCQT.time_axis, sCQT.band_axis, dB(Xcqt).T)
|
|
41
|
+
plt.clim(-100, 0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy.fft as fft
|
|
4
|
+
import scipy.signal as sg
|
|
5
|
+
import cicuetea.nsgf_cqt as NsgfCQT
|
|
6
|
+
from cicuetea.slicing import *
|
|
7
|
+
|
|
8
|
+
def pow2db(x):
|
|
9
|
+
return 10.0 * np.log10(np.abs(x))
|
|
10
|
+
|
|
11
|
+
def dB(X):
|
|
12
|
+
return 20.0 * np.log10(np.abs(X) + 1e-10)
|
|
13
|
+
|
|
14
|
+
def rms(x):
|
|
15
|
+
return np.sqrt(np.mean(np.abs(x)**2.0))
|
|
16
|
+
|
|
17
|
+
fs = 48000
|
|
18
|
+
nSamps = 2**18
|
|
19
|
+
frac = 1.0
|
|
20
|
+
fMin = 100
|
|
21
|
+
fMax = 10000
|
|
22
|
+
|
|
23
|
+
t = np.arange(nSamps) / fs
|
|
24
|
+
x = sg.chirp(t, fMin, t[-1], fMax, 'logarithmic')
|
|
25
|
+
w = sg.windows.kaiser(nSamps, 20)
|
|
26
|
+
x *= w
|
|
27
|
+
|
|
28
|
+
s1 = NsgfCQT.NsgfCQT('dense', fs, nSamps, frac)
|
|
29
|
+
s2 = NsgfCQT.NsgfCQT('sparse', fs, nSamps, frac)
|
|
30
|
+
|
|
31
|
+
X1 = s1.forward(x)
|
|
32
|
+
X2 = s2.forward(x)
|
|
33
|
+
X2 = s2.rasterize(X2)
|
|
34
|
+
|
|
35
|
+
plt.figure(1)
|
|
36
|
+
plt.clf()
|
|
37
|
+
plt.subplot(3, 1, 1)
|
|
38
|
+
plt.pcolormesh(s1.time_axis, s1.band_axis, dB(X1).T)
|
|
39
|
+
plt.yscale('log')
|
|
40
|
+
plt.clim(-100, 0)
|
|
41
|
+
|
|
42
|
+
plt.subplot(3, 1, 2)
|
|
43
|
+
plt.pcolormesh(s2.time_axis, s2.band_axis, dB(X2).T)
|
|
44
|
+
plt.yscale('log')
|
|
45
|
+
plt.clim(-100, 0)
|
|
46
|
+
|
|
47
|
+
plt.subplot(3, 1, 3)
|
|
48
|
+
plt.pcolormesh(s2.time_axis, s2.band_axis, dB(X1 - X2).T)
|
|
49
|
+
plt.yscale('log')
|
|
50
|
+
plt.clim(-100, 0)
|
|
51
|
+
|
|
52
|
+
g1 = s1.g[:s1.n_freqs//2+1,:]
|
|
53
|
+
h1 = fft.irfft(g1, s1.n_samples, 0)
|
|
54
|
+
h1 = fft.ifftshift(h1, 0)
|
|
55
|
+
h1 = h1 / np.max(np.abs(h1), 0)
|
|
56
|
+
|
|
57
|
+
h2 = np.zeros_like(h1)
|
|
58
|
+
for k in np.arange(s2.n_bands):
|
|
59
|
+
g_i = np.zeros(s2.n_samples)
|
|
60
|
+
g_i[s2.idxs[k]] = s2.g[k]
|
|
61
|
+
g_i = g_i[:s2.n_freqs//2+1]
|
|
62
|
+
h2[:,k] = fft.ifftshift(fft.irfft(g_i, s2.n_samples))
|
|
63
|
+
h2 = h2 / np.max(np.abs(h2), 0)
|
|
64
|
+
|
|
65
|
+
plt.figure(2)
|
|
66
|
+
plt.clf()
|
|
67
|
+
plt.subplot(2, 1, 1)
|
|
68
|
+
plt.semilogx(s1.band_axis, pow2db(np.mean(np.abs(X1)**2.0, 0)))
|
|
69
|
+
plt.semilogx(s2.band_axis, pow2db(np.mean(np.abs(X2)**2.0, 0)), "-.")
|
|
70
|
+
plt.legend(["dense", "sparse"])
|
|
71
|
+
|
|
72
|
+
plt.subplot(2, 1, 2)
|
|
73
|
+
plt.plot(s1.time_axis, pow2db(np.mean(np.abs(X1)**2.0, 1)))
|
|
74
|
+
plt.plot(s2.time_axis, pow2db(np.mean(np.abs(X2)**2.0, 1)), "-.")
|
|
75
|
+
plt.legend(["dense", "sparse"])
|
|
76
|
+
|
|
77
|
+
plt.figure(3)
|
|
78
|
+
plt.clf()
|
|
79
|
+
plt.subplot(1, 2, 1)
|
|
80
|
+
plt.plot(s1.time_axis, h1 + np.arange(s1.n_bands))
|
|
81
|
+
plt.xlim([-0.02, 0.02])
|
|
82
|
+
|
|
83
|
+
plt.subplot(1, 2, 2)
|
|
84
|
+
plt.plot(s2.time_axis, h2 + np.arange(s2.n_bands))
|
|
85
|
+
plt.xlim([-0.02, 0.02])
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import numpy as np
|
|
3
|
+
import scipy.signal as sg
|
|
4
|
+
import cicuetea.nsgf_cqt as NsgfCQT
|
|
5
|
+
from cicuetea.slicing import *
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dB(X):
|
|
9
|
+
return 20.0 * np.log10(np.abs(X))
|
|
10
|
+
|
|
11
|
+
def rms(x):
|
|
12
|
+
return np.sqrt(np.mean(np.abs(x)**2.0))
|
|
13
|
+
|
|
14
|
+
fs = 48000
|
|
15
|
+
nSamps = 2**20
|
|
16
|
+
frac = 1.0
|
|
17
|
+
fMin = 100
|
|
18
|
+
fMax = 10000
|
|
19
|
+
|
|
20
|
+
blockSize = 2**14
|
|
21
|
+
overlapSize = blockSize//2
|
|
22
|
+
win = np.sqrt(sg.windows.hann(blockSize, False))
|
|
23
|
+
|
|
24
|
+
t = np.arange(nSamps) / fs
|
|
25
|
+
x = sg.chirp(t, fMin, t[-1], fMax, 'logarithmic')
|
|
26
|
+
w = sg.windows.kaiser(nSamps, 20)
|
|
27
|
+
x *= w
|
|
28
|
+
x[:blockSize] = 0
|
|
29
|
+
x[-blockSize:] = 0
|
|
30
|
+
|
|
31
|
+
sRef = NsgfCQT.NsgfCQT('dense', fs, nSamps, frac)
|
|
32
|
+
XcqRef = sRef.forward(x)
|
|
33
|
+
|
|
34
|
+
s = NsgfCQT.NsgfCQT('dense', fs, blockSize, frac)
|
|
35
|
+
|
|
36
|
+
xBuf = slicer(x, blockSize, overlapSize)
|
|
37
|
+
xBuf *= win
|
|
38
|
+
XcqShort = [s.forward(xBuf[i,:]) for i in range(xBuf.shape[0])]
|
|
39
|
+
XcqShort = np.stack(XcqShort, axis=0)
|
|
40
|
+
XcqShort = XcqShort.transpose((0, 2, 1))
|
|
41
|
+
XcqShort *= win
|
|
42
|
+
Xcq = spectral_splicer(XcqShort, overlapSize)
|
|
43
|
+
XcqShort_ = spectral_slicer(Xcq, blockSize, overlapSize)
|
|
44
|
+
XcqShort_ *= win
|
|
45
|
+
xBuf_ = [s.inverse(XcqShort_[i,:,:].T) for i in range(XcqShort_.shape[0])]
|
|
46
|
+
xBuf_ = np.stack(xBuf_, axis=0)
|
|
47
|
+
xBuf_ *= win
|
|
48
|
+
x_ = splicer(xBuf_, overlapSize)
|
|
49
|
+
|
|
50
|
+
err1 = rms(XcqRef - Xcq)
|
|
51
|
+
err2 = rms(x - x_)
|
|
52
|
+
|
|
53
|
+
plt.figure(1)
|
|
54
|
+
plt.clf()
|
|
55
|
+
plt.subplot(3, 1, 1)
|
|
56
|
+
plt.plot(t, x)
|
|
57
|
+
plt.xlabel("Time")
|
|
58
|
+
plt.ylabel("Amplitude")
|
|
59
|
+
plt.title("Original Time-domain signal")
|
|
60
|
+
|
|
61
|
+
plt.subplot(3, 1, 2)
|
|
62
|
+
plt.plot(t, x_)
|
|
63
|
+
plt.xlabel("Time")
|
|
64
|
+
plt.ylabel("Amplitude")
|
|
65
|
+
plt.title("Reconstructed Time-domain signal")
|
|
66
|
+
|
|
67
|
+
plt.subplot(3, 1, 3)
|
|
68
|
+
plt.plot(t, x - x_)
|
|
69
|
+
plt.tight_layout()
|
|
70
|
+
plt.xlabel("Time")
|
|
71
|
+
plt.ylabel("Amplitude")
|
|
72
|
+
plt.title("Signal error")
|
|
73
|
+
|
|
74
|
+
plt.figure(2)
|
|
75
|
+
plt.clf()
|
|
76
|
+
cRange = [-60, 0]
|
|
77
|
+
plt.subplot(2, 1, 1)
|
|
78
|
+
plt.pcolormesh(t, s.band_axis, dB(Xcq.T))
|
|
79
|
+
plt.yscale('log')
|
|
80
|
+
plt.clim(cRange)
|
|
81
|
+
plt.colorbar()
|
|
82
|
+
plt.xlabel("Time")
|
|
83
|
+
plt.ylabel("Frequency")
|
|
84
|
+
plt.title("CQT as Computed from spliced short NSGF-CQT")
|
|
85
|
+
|
|
86
|
+
plt.subplot(2, 1, 2)
|
|
87
|
+
plt.pcolormesh(t, s.band_axis, dB(XcqRef.T))
|
|
88
|
+
plt.yscale('log')
|
|
89
|
+
plt.clim(cRange)
|
|
90
|
+
plt.colorbar()
|
|
91
|
+
plt.xlabel("Time")
|
|
92
|
+
plt.ylabel("Frequency")
|
|
93
|
+
plt.title("CQT as Computed from dense signal")
|
|
94
|
+
|
|
95
|
+
plt.tight_layout()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Psychoacoustic Scale Conversion Functions
|
|
3
|
+
|
|
4
|
+
This module provides functions to convert between frequency (Hz) and various psychoacoustic scales:
|
|
5
|
+
- Mel scale
|
|
6
|
+
- ERB (Equivalent Rectangular Bandwidth) scale
|
|
7
|
+
- Bark scale
|
|
8
|
+
|
|
9
|
+
References and usage notes are included in the docstrings of each function.
|
|
10
|
+
"""
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
def freq2mel(freq):
|
|
14
|
+
"""
|
|
15
|
+
Frequency to Mel conversion
|
|
16
|
+
|
|
17
|
+
The mel scale is a linearization of the pitch space perception. The idea is that musical intervals are not exactly logarithmically spaced in the frequency axis. Accordingly, a span of 4 perfect octaves (2^4) is perceived as wider than what 4 musical octaves should be, for example. Psychoacoustical analysis was performed to obtain statistical proof of said pitch space compression.
|
|
18
|
+
|
|
19
|
+
This mainly applies for perception of pitch space and does not take into account the implications of more complex tones.
|
|
20
|
+
|
|
21
|
+
(Notice that this concept has been criticized even by one of its main authors and needs further revision. It is possible that other scales might be more relevant.)
|
|
22
|
+
|
|
23
|
+
Parameters:
|
|
24
|
+
freq (float or array-like): Frequency in Hz
|
|
25
|
+
Returns:
|
|
26
|
+
float or ndarray: Value(s) in Mel scale
|
|
27
|
+
"""
|
|
28
|
+
freq = np.asarray(freq)
|
|
29
|
+
return 1127 * np.log1p(freq / 700)
|
|
30
|
+
|
|
31
|
+
def mel2freq(mel):
|
|
32
|
+
"""
|
|
33
|
+
Mel to frequency conversion
|
|
34
|
+
|
|
35
|
+
See freq2mel for details on the Mel scale.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
mel (float or array-like): Value(s) in Mel scale
|
|
39
|
+
Returns:
|
|
40
|
+
float or ndarray: Frequency in Hz
|
|
41
|
+
"""
|
|
42
|
+
mel = np.asarray(mel)
|
|
43
|
+
return 700 * (np.exp(mel / 1127) - 1)
|
|
44
|
+
|
|
45
|
+
def freq2erb(freq):
|
|
46
|
+
"""
|
|
47
|
+
Frequency to Equivalent Rectangular Bandwidth (ERB) conversion
|
|
48
|
+
|
|
49
|
+
The ERB scale is a model of listening critical bands where a dense ERB shares particular perceptual characteristics based on masking curves, equal loudness perception, and even complex tone integration. Notice however that any of these can be affected by other objective signal properties like loudness and timbre.
|
|
50
|
+
|
|
51
|
+
Reference:
|
|
52
|
+
[1] Moore and Glasberg "A Revision of Zwicker's Loudness Model," ACTA Acustica, vol. 82, pp. 335-345, 1996
|
|
53
|
+
|
|
54
|
+
Parameters:
|
|
55
|
+
freq (float or array-like): Frequency in Hz
|
|
56
|
+
Returns:
|
|
57
|
+
float or ndarray: Value(s) in ERB scale
|
|
58
|
+
"""
|
|
59
|
+
freq = np.asarray(freq)
|
|
60
|
+
return 21.4 * np.log10(4.37 * freq / 1000 + 1.0)
|
|
61
|
+
|
|
62
|
+
def erb2freq(erb):
|
|
63
|
+
"""
|
|
64
|
+
Equivalent Rectangular Bandwidth (ERB) to frequency conversion
|
|
65
|
+
|
|
66
|
+
See freq2erb for details on the ERB scale.
|
|
67
|
+
|
|
68
|
+
Parameters:
|
|
69
|
+
erb (float or array-like): Value(s) in ERB scale
|
|
70
|
+
Returns:
|
|
71
|
+
float or ndarray: Frequency in Hz
|
|
72
|
+
"""
|
|
73
|
+
erb = np.asarray(erb)
|
|
74
|
+
return 1000 * (10 ** (erb / 21.4) - 1.0) / 4.37
|
|
75
|
+
|
|
76
|
+
def freq2bark(freq):
|
|
77
|
+
"""
|
|
78
|
+
Frequency to Bark scale conversion
|
|
79
|
+
|
|
80
|
+
The bark scale is a psychoacoustically defined scale of perception of frequency space in which the perception of such space is linearized. The idea behind it is that human perception is based on critical bands of hearing that share specific behaviors. The bandwidth of such frequencies is not constant across frequency, so the purpose of the bark scale is to quantify such bandwidth across frequencies.
|
|
81
|
+
|
|
82
|
+
Reference: H. Traunmüller (1990) "Analytical expressions for the tonotopic sensory scale" J. Acoust. Soc. Am. 88: 97-100.
|
|
83
|
+
|
|
84
|
+
Parameters:
|
|
85
|
+
freq (float or array-like): Frequency in Hz
|
|
86
|
+
Returns:
|
|
87
|
+
float or ndarray: Value(s) in Bark scale
|
|
88
|
+
"""
|
|
89
|
+
freq = np.asarray(freq)
|
|
90
|
+
return (26.81 / (1 + 1960 / freq)) - 0.53
|
|
91
|
+
|
|
92
|
+
def bark2freq(bark):
|
|
93
|
+
"""
|
|
94
|
+
Bark scale to frequency conversion
|
|
95
|
+
|
|
96
|
+
See freq2bark for details on the Bark scale.
|
|
97
|
+
|
|
98
|
+
Parameters:
|
|
99
|
+
bark (float or array-like): Value(s) in Bark scale
|
|
100
|
+
Returns:
|
|
101
|
+
float or ndarray: Frequency in Hz
|
|
102
|
+
"""
|
|
103
|
+
bark = np.asarray(bark)
|
|
104
|
+
return 1960 / (26.81 / (bark + 0.53) - 1)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cicuetea"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Real-time invertible Constant-Q Transform (CQT) engine based on nonstationary Gabor frames — Python reference implementation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Juan Sierra" }]
|
|
13
|
+
keywords = ["constant-q transform", "cqt", "nsgf", "spectral processing", "audio", "dsp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio :: Analysis",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"numpy",
|
|
23
|
+
"scipy",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
demo = ["matplotlib"]
|
|
28
|
+
test = ["pytest"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/jdsierral/CiCueTea"
|
|
32
|
+
Documentation = "https://jdsierral.github.io/CiCueTea/"
|
|
33
|
+
Repository = "https://github.com/jdsierral/CiCueTea"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/cicuetea"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Python reference implementation of CiCueTea's NSGF Constant-Q Transform."""
|
|
2
|
+
|
|
3
|
+
from .nsgf_cqt import NsgfCQT, NsgfVQT
|
|
4
|
+
from .slicing import slicer, splicer, spectral_slicer, spectral_splicer
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"NsgfCQT",
|
|
10
|
+
"NsgfVQT",
|
|
11
|
+
"slicer",
|
|
12
|
+
"splicer",
|
|
13
|
+
"spectral_slicer",
|
|
14
|
+
"spectral_splicer",
|
|
15
|
+
]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import scipy.fft as fft
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NsgfCQT:
|
|
6
|
+
def __init__(self, mode, sample_rate, n_samples,
|
|
7
|
+
frac=1/12, f_min=1e2, f_max=1e4, f_ref=1e3, threshold=1e-6):
|
|
8
|
+
sample_rate = float(sample_rate)
|
|
9
|
+
n_samples = int(n_samples)
|
|
10
|
+
frac = float(frac)
|
|
11
|
+
f_min = float(f_min)
|
|
12
|
+
f_max = float(f_max)
|
|
13
|
+
f_ref = float(f_ref)
|
|
14
|
+
|
|
15
|
+
# Structural validity
|
|
16
|
+
assert sample_rate > 0, "Sample rate must be positive"
|
|
17
|
+
assert n_samples > 0, "Block must be non-empty"
|
|
18
|
+
if mode == "sparse": # power-of-two spans (pad_indices) assume it
|
|
19
|
+
assert (n_samples & (n_samples - 1)) == 0, "n_samples must be a power of 2"
|
|
20
|
+
assert frac > 0, "frac (reciprocal of bands per octave) must be positive"
|
|
21
|
+
assert f_ref > 0, "Reference frequency must be positive"
|
|
22
|
+
assert f_min > 0, "Lower bound must be positive"
|
|
23
|
+
assert f_min < f_max, "The range must be a range (f_min < f_max)"
|
|
24
|
+
assert f_max * 2 < sample_rate, "Higher bound must be below nyquist"
|
|
25
|
+
|
|
26
|
+
self.min_bw = 4
|
|
27
|
+
|
|
28
|
+
n_bands_up = int(np.ceil(1 / frac * np.log2(f_max / f_ref)))
|
|
29
|
+
n_bands_dn = int(np.ceil(1 / frac * np.log2(f_ref / f_min)))
|
|
30
|
+
n_bands = n_bands_dn + n_bands_up + 1
|
|
31
|
+
n_freqs = n_samples
|
|
32
|
+
bands = np.arange(-n_bands_dn, n_bands_up + 1)
|
|
33
|
+
band_axis = f_ref * 2.0 ** (frac * bands)
|
|
34
|
+
time_axis = np.arange(-n_samples / 2, n_samples / 2) / sample_rate
|
|
35
|
+
freq_axis = np.arange(n_freqs) * sample_rate / n_freqs
|
|
36
|
+
|
|
37
|
+
c = np.log(4) / (frac ** 2.0) # Horizontal Scale Factor
|
|
38
|
+
# log2(0) at the DC bin is fine: -inf yields a Gaussian weight of exactly 0
|
|
39
|
+
with np.errstate(divide="ignore"):
|
|
40
|
+
outer_diff = np.subtract.outer(np.log2(freq_axis), np.log2(band_axis))
|
|
41
|
+
g = np.exp(-c * outer_diff ** 2.0) # Analytic Gaussians
|
|
42
|
+
g[np.where(freq_axis < band_axis[0]), 0] = 1 # Make lowest band an LPF
|
|
43
|
+
g[np.where(freq_axis > band_axis[-1]), -1] = 1 # Make highest band an HPF
|
|
44
|
+
|
|
45
|
+
if mode == "sparse": # In sparse mode truncate gaussians
|
|
46
|
+
g[np.where(g < threshold)] = 0
|
|
47
|
+
|
|
48
|
+
d = np.sum(g ** 2.0, 1)
|
|
49
|
+
|
|
50
|
+
# Measured frame health. Two failure modes: coverage gaps (bins no atom reaches) show up as an ill-conditioned
|
|
51
|
+
# frame operator; unresolved atoms (bands too narrow for the grid) are invisible in d and are caught by per-atom
|
|
52
|
+
# support instead. (The old identity check rms(sum(g*g_dual) - 1) was algebraically zero by construction — it
|
|
53
|
+
# could only ever catch exact 0/0 NaNs.)
|
|
54
|
+
dh = d[freq_axis <= sample_rate / 2]
|
|
55
|
+
assert dh.min() > 1e-6 * dh.max(), \
|
|
56
|
+
"Frame operator ill-conditioned: coverage gaps (Q too high or threshold too aggressive)"
|
|
57
|
+
support = np.count_nonzero(g > threshold, axis=0)
|
|
58
|
+
assert support.min() >= self.min_bw, \
|
|
59
|
+
"Atoms unresolved by the frequency grid (Q too high for this block size)"
|
|
60
|
+
|
|
61
|
+
g_dual = g / d[:, None] # Compute the dual frame
|
|
62
|
+
|
|
63
|
+
g[np.where(freq_axis > sample_rate / 2), :] = 0
|
|
64
|
+
g_dual[np.where(freq_axis > sample_rate / 2), :] = 0
|
|
65
|
+
|
|
66
|
+
self.sample_rate = sample_rate
|
|
67
|
+
self.n_samples = n_samples
|
|
68
|
+
self.n_freqs = n_freqs
|
|
69
|
+
self.n_bands = n_bands
|
|
70
|
+
self.f_min = f_min
|
|
71
|
+
self.f_max = f_max
|
|
72
|
+
self.f_ref = f_ref
|
|
73
|
+
self.time_axis = time_axis
|
|
74
|
+
self.band_axis = band_axis
|
|
75
|
+
self.freq_axis = freq_axis
|
|
76
|
+
self.g = g
|
|
77
|
+
self.d = d
|
|
78
|
+
self.g_dual = g_dual
|
|
79
|
+
self.mode = mode
|
|
80
|
+
|
|
81
|
+
if mode == "sparse":
|
|
82
|
+
idxs = [None] * n_bands
|
|
83
|
+
g_list = [None] * n_bands
|
|
84
|
+
g_dual_list = [None] * n_bands
|
|
85
|
+
shifts = [None] * n_bands
|
|
86
|
+
|
|
87
|
+
for k in range(n_bands):
|
|
88
|
+
ii = np.where(g[:, k] != 0)[0]
|
|
89
|
+
ii = NsgfCQT.pad_indices(ii)
|
|
90
|
+
n_coefs = len(ii)
|
|
91
|
+
offset = ii[0]
|
|
92
|
+
idxs[k] = ii
|
|
93
|
+
n = np.arange(n_coefs)
|
|
94
|
+
shifts[k] = np.exp(1j * 2 * np.pi * offset * n / n_coefs)
|
|
95
|
+
g_list[k] = g[ii,k]
|
|
96
|
+
g_dual_list[k] = g_dual[ii,k]
|
|
97
|
+
|
|
98
|
+
self.g = g_list
|
|
99
|
+
self.g_dual = g_dual_list
|
|
100
|
+
self.idxs = idxs
|
|
101
|
+
self.shifts = shifts
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def forward(self, x):
|
|
105
|
+
X = fft.fft(x, self.n_samples) / self.n_samples
|
|
106
|
+
|
|
107
|
+
if self.mode == "dense":
|
|
108
|
+
X_cq = 2 * self.n_samples * fft.ifft(X[:, None] * self.g, self.n_samples, 0)
|
|
109
|
+
elif self.mode == "sparse":
|
|
110
|
+
X_cq = [None] * self.n_bands
|
|
111
|
+
for k in range(self.n_bands):
|
|
112
|
+
n_coefs = float(len(self.idxs[k]))
|
|
113
|
+
Xi = fft.ifft(X[self.idxs[k]] * self.g[k])
|
|
114
|
+
Xi = Xi * 2 * n_coefs * self.shifts[k]
|
|
115
|
+
X_cq[k] = Xi
|
|
116
|
+
return X_cq
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def inverse(self, X_cq):
|
|
120
|
+
if self.mode == "dense":
|
|
121
|
+
X = 1.0 / (2.0 * self.n_samples) * np.sum(fft.fft(X_cq, self.n_samples, 0) * self.g_dual, 1)
|
|
122
|
+
elif self.mode == "sparse":
|
|
123
|
+
X = np.zeros(self.n_samples, dtype=np.complex128)
|
|
124
|
+
for k in range(self.n_bands):
|
|
125
|
+
n_coefs = len(self.idxs[k])
|
|
126
|
+
Xi = X_cq[k] * (self.shifts[k].conj() / (2.0 * n_coefs))
|
|
127
|
+
Xi = fft.fft(Xi) * self.g_dual[k]
|
|
128
|
+
X[self.idxs[k]] += Xi
|
|
129
|
+
x = fft.irfft(X, self.n_samples) * self.n_samples
|
|
130
|
+
return x
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def pad_indices(indices):
|
|
135
|
+
i0 = indices[0]
|
|
136
|
+
n_idx = indices.size
|
|
137
|
+
if n_idx < 4:
|
|
138
|
+
n_idx = 4
|
|
139
|
+
n_idx_pow2 = int(2 ** np.ceil(np.log2(n_idx)))
|
|
140
|
+
indices = i0 + np.arange(n_idx_pow2)
|
|
141
|
+
return indices
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def rasterize(self, X_cq):
|
|
145
|
+
"""Reconstruct the dense [n_samples, n_bands] representation from
|
|
146
|
+
sparse coefficients: undo the per-band phase and scaling, recover the
|
|
147
|
+
band's span spectrum, and embed it at its true bins. Exactly equal to
|
|
148
|
+
the dense transform's columns (up to the sparsity threshold), unlike
|
|
149
|
+
naive bandlimited interpolation, which assumes baseband input while
|
|
150
|
+
the coefficients carry the (aliased) carrier."""
|
|
151
|
+
X_r = np.zeros([self.n_samples, self.n_bands], dtype=np.complex128)
|
|
152
|
+
for k in range(self.n_bands):
|
|
153
|
+
n_coefs = len(self.idxs[k])
|
|
154
|
+
Xi = X_cq[k] * (self.shifts[k].conj() / (2.0 * n_coefs))
|
|
155
|
+
spec = np.zeros(self.n_freqs, dtype=np.complex128)
|
|
156
|
+
spec[self.idxs[k]] = fft.fft(Xi)
|
|
157
|
+
X_r[:, k] = 2 * self.n_samples * fft.ifft(spec)
|
|
158
|
+
return X_r
|
|
159
|
+
|
|
160
|
+
class NsgfVQT(NsgfCQT):
|
|
161
|
+
def __init__(self, mode, sample_rate, n_samples, f_map=np.log2,
|
|
162
|
+
frac=1/12, f_min=1e2, f_max=1e4, f_ref=1e3, threshold=1e-6):
|
|
163
|
+
sample_rate = float(sample_rate)
|
|
164
|
+
n_samples = int(n_samples)
|
|
165
|
+
frac = float(frac)
|
|
166
|
+
f_min = float(f_min)
|
|
167
|
+
f_max = float(f_max)
|
|
168
|
+
f_ref = float(f_ref)
|
|
169
|
+
# Structural validity: each condition provably breaks the transform on
|
|
170
|
+
# its own. Feasibility with fuzzy boundaries (Q vs. block length) is
|
|
171
|
+
# measured on the constructed frame below, not predicted from the
|
|
172
|
+
# parameters; sub-octave ranges are legitimate. (The C++ version
|
|
173
|
+
# additionally requires power-of-two n_samples in both modes, since
|
|
174
|
+
# its FFT backends do.)
|
|
175
|
+
assert sample_rate > 0, "Sample rate must be positive"
|
|
176
|
+
assert n_samples > 0, "Block must be non-empty"
|
|
177
|
+
if mode == "sparse": # power-of-two spans (pad_indices) assume it
|
|
178
|
+
assert (n_samples & (n_samples - 1)) == 0, "n_samples must be a power of 2"
|
|
179
|
+
assert frac > 0, "frac (reciprocal of bands per octave) must be positive"
|
|
180
|
+
assert f_ref > 0, "Reference frequency must be positive"
|
|
181
|
+
assert f_min > 0, "Lower bound must be positive"
|
|
182
|
+
assert f_min < f_max, "The range must be a range (f_min < f_max)"
|
|
183
|
+
assert f_max * 2 < sample_rate, "Higher bound must be below nyquist"
|
|
184
|
+
|
|
185
|
+
self.min_bw = 4
|
|
186
|
+
|
|
187
|
+
n_bands_up = int(np.ceil(1 / frac * (f_map(f_max) - f_map(f_ref))))
|
|
188
|
+
n_bands_dn = int(np.ceil(1 / frac * (f_map(f_ref) - f_map(f_min))))
|
|
189
|
+
n_bands = n_bands_dn + n_bands_up + 1
|
|
190
|
+
n_freqs = n_samples
|
|
191
|
+
bands = np.arange(-n_bands_dn, n_bands_up + 1)
|
|
192
|
+
band_axis = f_map(f_ref) + (frac * bands)
|
|
193
|
+
time_axis = np.arange(-n_samples / 2, n_samples / 2) / sample_rate
|
|
194
|
+
freq_axis = np.arange(n_freqs) * sample_rate / n_freqs
|
|
195
|
+
|
|
196
|
+
c = np.log(4) / (frac ** 2.0) # Horizontal Scale Factor
|
|
197
|
+
# f_map(0) at the DC bin may be -inf (e.g. log2): Gaussian weight becomes exactly 0
|
|
198
|
+
with np.errstate(divide="ignore"):
|
|
199
|
+
warped_fax = f_map(freq_axis)
|
|
200
|
+
outer_diff = np.subtract.outer(warped_fax, band_axis)
|
|
201
|
+
g = np.exp(-c * outer_diff ** 2.0) # Analytic Gaussians
|
|
202
|
+
g[np.where(warped_fax < band_axis[0]), 0] = 1 # Make lowest band an LPF
|
|
203
|
+
g[np.where(warped_fax > band_axis[-1]), -1] = 1 # Make highest band an HPF
|
|
204
|
+
|
|
205
|
+
if mode == "sparse": # In sparse mode truncate gaussians
|
|
206
|
+
g[np.where(g < threshold)] = 0
|
|
207
|
+
|
|
208
|
+
d = np.sum(g ** 2.0, 1)
|
|
209
|
+
|
|
210
|
+
# Measured frame health (mirrors the C++ checks). Two failure modes:
|
|
211
|
+
# coverage gaps (bins no atom reaches) show up as an ill-conditioned
|
|
212
|
+
# frame operator; unresolved atoms (bands too narrow for the grid)
|
|
213
|
+
# are invisible in d and are caught by per-atom support instead.
|
|
214
|
+
# (The old identity check rms(sum(g*g_dual) - 1) was algebraically
|
|
215
|
+
# zero by construction — it could only ever catch exact 0/0 NaNs.)
|
|
216
|
+
dh = d[freq_axis <= sample_rate / 2]
|
|
217
|
+
assert dh.min() > 1e-6 * dh.max(), \
|
|
218
|
+
"Frame operator ill-conditioned: coverage gaps (Q too high or threshold too aggressive)"
|
|
219
|
+
support = np.count_nonzero(g > threshold, axis=0)
|
|
220
|
+
assert support.min() >= self.min_bw, \
|
|
221
|
+
"Atoms unresolved by the frequency grid (Q too high for this block size)"
|
|
222
|
+
|
|
223
|
+
g_dual = g / d[:, None] # Compute the dual frame
|
|
224
|
+
|
|
225
|
+
g[np.where(freq_axis > sample_rate / 2), :] = 0
|
|
226
|
+
g_dual[np.where(freq_axis > sample_rate / 2), :] = 0
|
|
227
|
+
|
|
228
|
+
self.sample_rate = sample_rate
|
|
229
|
+
self.n_samples = n_samples
|
|
230
|
+
self.n_freqs = n_freqs
|
|
231
|
+
self.n_bands = n_bands
|
|
232
|
+
self.f_map = f_map
|
|
233
|
+
self.f_min = f_min
|
|
234
|
+
self.f_max = f_max
|
|
235
|
+
self.f_ref = f_ref
|
|
236
|
+
self.time_axis = time_axis
|
|
237
|
+
self.band_axis = band_axis
|
|
238
|
+
self.freq_axis = freq_axis
|
|
239
|
+
self.g = g
|
|
240
|
+
self.d = d
|
|
241
|
+
self.g_dual = g_dual
|
|
242
|
+
self.mode = mode
|
|
243
|
+
|
|
244
|
+
if mode == "sparse":
|
|
245
|
+
idxs = [None] * n_bands
|
|
246
|
+
g_list = [None] * n_bands
|
|
247
|
+
g_dual_list = [None] * n_bands
|
|
248
|
+
shifts = [None] * n_bands
|
|
249
|
+
|
|
250
|
+
for k in range(n_bands):
|
|
251
|
+
ii = np.where(g[:, k] != 0)[0]
|
|
252
|
+
ii = NsgfCQT.pad_indices(ii)
|
|
253
|
+
n_coefs = len(ii)
|
|
254
|
+
offset = ii[0]
|
|
255
|
+
idxs[k] = ii
|
|
256
|
+
n = np.arange(n_coefs)
|
|
257
|
+
shifts[k] = np.exp(1j * 2 * np.pi * offset * n / n_coefs)
|
|
258
|
+
g_list[k] = g[ii,k]
|
|
259
|
+
g_dual_list[k] = g_dual[ii,k]
|
|
260
|
+
|
|
261
|
+
self.g = g_list
|
|
262
|
+
self.g_dual = g_dual_list
|
|
263
|
+
self.idxs = idxs
|
|
264
|
+
self.shifts = shifts
|
|
265
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
def slicer(x, block_size, overlap_size):
|
|
4
|
+
"""
|
|
5
|
+
Slice a 1D array into overlapping blocks.
|
|
6
|
+
|
|
7
|
+
Parameters:
|
|
8
|
+
x (np.ndarray): Input 1D array.
|
|
9
|
+
block_size (int): Size of each block.
|
|
10
|
+
overlap_size (int): Number of samples that overlap between blocks.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
np.ndarray: 2D array of shape (n_blocks, block_size) containing the blocks.
|
|
14
|
+
"""
|
|
15
|
+
n_samps = x.size
|
|
16
|
+
hop_size = block_size - overlap_size # Step size between blocks
|
|
17
|
+
# Compute indices for each block
|
|
18
|
+
idx = np.add.outer(np.arange(0, n_samps - block_size + 1, hop_size), np.arange(block_size))
|
|
19
|
+
return x[idx]
|
|
20
|
+
|
|
21
|
+
def splicer(x, overlap_size):
|
|
22
|
+
"""
|
|
23
|
+
Reconstruct a 1D array from overlapping blocks (inverse of slicer).
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
x (np.ndarray): 2D array of shape (n_blocks, block_size) containing the blocks.
|
|
27
|
+
overlap_size (int): Number of samples that overlap between blocks.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
np.ndarray: Reconstructed 1D array.
|
|
31
|
+
"""
|
|
32
|
+
n_blocks, block_size = x.shape
|
|
33
|
+
hop_size = block_size - overlap_size
|
|
34
|
+
pos = 0
|
|
35
|
+
|
|
36
|
+
n_samples = hop_size * n_blocks + overlap_size # Total length of reconstructed signal
|
|
37
|
+
y = np.zeros(n_samples, dtype=x.dtype) # follow the input (real stays real)
|
|
38
|
+
|
|
39
|
+
for i in np.arange(n_blocks):
|
|
40
|
+
ii = np.arange(block_size) + pos # Indices for current block
|
|
41
|
+
y[ii] += x[i, :]
|
|
42
|
+
pos += hop_size
|
|
43
|
+
return y
|
|
44
|
+
|
|
45
|
+
def spectral_slicer(X, block_size, overlap_size):
|
|
46
|
+
"""
|
|
47
|
+
Slice a 2D array (e.g., time-frequency representation) into overlapping blocks along the first axis.
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
X (np.ndarray): 2D array of shape (n_samples, n_bands).
|
|
51
|
+
block_size (int): Size of each block.
|
|
52
|
+
overlap_size (int): Number of samples that overlap between blocks.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
np.ndarray: 3D array of shape (n_blocks, n_bands, block_size) containing the blocks.
|
|
56
|
+
"""
|
|
57
|
+
n_samps, n_bands = X.shape
|
|
58
|
+
hop_size = block_size - overlap_size
|
|
59
|
+
n_blocks = int(np.ceil((n_samps - overlap_size) / hop_size))
|
|
60
|
+
X_block = np.zeros([n_blocks, n_bands, block_size], dtype='complex128')
|
|
61
|
+
for k in np.arange(n_bands):
|
|
62
|
+
# Slice each band independently
|
|
63
|
+
X_block[:, k, :] = slicer(X[:, k], block_size, overlap_size)
|
|
64
|
+
return X_block
|
|
65
|
+
|
|
66
|
+
def spectral_splicer(X_block, overlap_size):
|
|
67
|
+
"""
|
|
68
|
+
Reconstruct a 2D array from overlapping blocks (inverse of spectral_slicer).
|
|
69
|
+
|
|
70
|
+
Parameters:
|
|
71
|
+
X_block (np.ndarray): 3D array of shape (n_blocks, n_bands, block_size) containing the blocks.
|
|
72
|
+
overlap_size (int): Number of samples that overlap between blocks.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
np.ndarray: 2D array of shape (n_samples, n_bands) reconstructed from the blocks.
|
|
76
|
+
"""
|
|
77
|
+
n_blocks, n_bands, block_size = X_block.shape
|
|
78
|
+
hop_size = block_size - overlap_size
|
|
79
|
+
n_samps = hop_size * n_blocks + overlap_size # Total length of reconstructed signal
|
|
80
|
+
|
|
81
|
+
X = np.zeros([n_samps, n_bands], dtype='complex128')
|
|
82
|
+
for k in np.arange(n_bands):
|
|
83
|
+
# Reconstruct each band independently
|
|
84
|
+
X[:, k] = splicer(X_block[:, k, :], overlap_size)
|
|
85
|
+
return X
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from cicuetea import NsgfCQT, NsgfVQT
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _rms(x):
|
|
8
|
+
return np.sqrt(np.mean(np.abs(x) ** 2.0))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("mode", ["dense", "sparse"])
|
|
12
|
+
@pytest.mark.parametrize("frac", [1.0, 1 / 12])
|
|
13
|
+
def test_cqt_roundtrip(mode, frac):
|
|
14
|
+
fs = 48000
|
|
15
|
+
n_samples = 2**14
|
|
16
|
+
x = np.random.default_rng(0).standard_normal(n_samples)
|
|
17
|
+
|
|
18
|
+
cqt = NsgfCQT(mode, fs, n_samples, frac=frac, f_min=100, f_max=10000)
|
|
19
|
+
y = cqt.inverse(cqt.forward(x))
|
|
20
|
+
|
|
21
|
+
assert _rms(x - y) < 1e-10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_vqt_roundtrip():
|
|
25
|
+
fs = 48000
|
|
26
|
+
n_samples = 2**14
|
|
27
|
+
x = np.random.default_rng(1).standard_normal(n_samples)
|
|
28
|
+
|
|
29
|
+
vqt = NsgfVQT("dense", fs, n_samples, f_map=np.log2, frac=1 / 12)
|
|
30
|
+
y = vqt.inverse(vqt.forward(x))
|
|
31
|
+
|
|
32
|
+
assert _rms(x - y) < 1e-10
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_rasterize_matches_dense():
|
|
36
|
+
fs = 48000
|
|
37
|
+
n_samples = 2**14
|
|
38
|
+
x = np.random.default_rng(2).standard_normal(n_samples)
|
|
39
|
+
|
|
40
|
+
dense = NsgfCQT("dense", fs, n_samples, frac=1 / 12)
|
|
41
|
+
sparse = NsgfCQT("sparse", fs, n_samples, frac=1 / 12)
|
|
42
|
+
|
|
43
|
+
X_dense = dense.forward(x)
|
|
44
|
+
X_sparse = sparse.rasterize(sparse.forward(x))
|
|
45
|
+
|
|
46
|
+
assert _rms(X_dense - X_sparse) < 1e-5
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_rejects_invalid_range():
|
|
50
|
+
with pytest.raises(AssertionError):
|
|
51
|
+
NsgfCQT("dense", 48000, 2**14, frac=1 / 12, f_min=10000, f_max=100)
|