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.
@@ -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
@@ -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).
@@ -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)