├── .gitignore ├── LICENSE ├── README.md ├── matlab ├── AAIIR_demo.m ├── AA_osc_cplx.m ├── generateEscalationII_w3.m ├── generateWavetableSaw.m └── octave-workspace ├── python ├── bl_waveform.py ├── decimator.py ├── legacy.py ├── main.py ├── metrics.py ├── mipmap.py ├── utils.py └── waveform.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.wav 2 | __pycache__ 3 | python/__pycache__ 4 | *.png 5 | matlab/octave-workspace 6 | octave-workspace 7 | .vscode 8 | venv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Maxime COUTANT 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Experimentations on ADAA for wavetable oscillators 2 | This repository contains all the python experiments I made based on the IEEE research paper [Antiderivative Antialiasing for Arbitrary Waveform Generation](https://ieeexplore.ieee.org/document/9854137) 3 | 4 | The paper provided an algorithm, some results and some matlab demo code which you can find [here](https://dangelo.audio/ieee-talsp-aaiir-osc.html) 5 | 6 | This work was presented at the [Audio Developer Conference 2023](https://audio.dev/conference/) alongside its C++ implementation for real-time [here](https://github.com/maxmarsc/libadawata). 7 | 8 | The code contained in here is certainly not production ready, but I made what I could to understand, replicate, and further adapt the algorithm to a real-time scenario. 9 | 10 | *Keep in mind that I'm not a DSP specialist, if you find something weird or buggy in my code don't hesitate to tell it. Also this repository is not dedicated to explain the algorithm in any case.* 11 | 12 | ## What's included 13 | This repository contains 3 mains parts : 14 | - `matlab` : contains the Matlab code of the paper demo, slightly modified to run with Octave 15 | - `python` : contains the different versions of the algorithm and some tools to 16 | analyze the results (metrics, graphs...) 17 | - `python/legacy.py` : contains some iterations of my work when adapting the algorithm. 18 | It's only provided for R&D legacy and should not be considered reliable 19 | 20 | ## What changed since ADC23 21 | - Matlab is no longer required to compute SNR. Both SNR and SINAD computations 22 | are working as expected and in full python code 23 | - Following SINAD fixes, I changed the mipmap transition thresholds, algorithm 24 | should be a little slower 25 | - The cross-fading is no longer using this weird frequency-based strategy I presented 26 | at ADC23, now using a more classic time-based strategy 27 | 28 | 29 | ## Python experimentations 30 | ### Requirements 31 | The following tools are required : 32 | - `libsamplerate` : for mipmapping resampling 33 | - `libsndfile` : for audio exporting 34 | 35 | On Ubuntu you can install `libsamplerate` and `libsndfile` with the following command: 36 | ```bash 37 | apt-get install -y libsamplerate0 libsndfile1 38 | ``` 39 | 40 | After that you will need to install the python requirements : 41 | ```bash 42 | pip install -r requirements.txt 43 | ``` 44 | 45 | ### How to use 46 | I provide a main python script that can performs three tasks, on different version 47 | of both the ADAA algorithm, and its alternatives (lerp + oversampling) : 48 | - Metrics computation (SNR and SINAD) 49 | - Sweep test spectrogram plot 50 | - Power spectral density plot 51 | 52 | Some values still needs to be modified manually in the `main.py` file depending on your use case: 53 | - `DURATION_S` : The duration of generated audio, might lead to high ram usage if too high 54 | - `FREQS` : A list of frequencies to generate for (only in psd/metrics modes) 55 | - `ALGOS_OPTIONS` : A list of all the algorithm to test 56 | - `NUM_PROCESS` : The number of parallel process, maxed out to 20, mined out to you ncpus 57 | - `SAMPLERATE` 58 | 59 | #### Metrics 60 | For the metrics mode use the following options : 61 | ```bash 62 | python python/main.py metrics [--export {snr,sinad,both,none}] [--export-dir EXPORT_DIR] [--export-audio] [--export-phase] 63 | ``` 64 | 65 | You'd usually want to add all the frequencies you want to test in `FREQS`. 66 | The script will write the metrics in CSV files. 67 | 68 | #### Sweep test 69 | For the metrics mode use the following options : 70 | ```bash 71 | python python/main.py sweep [--export-dir EXPORT_DIR] [--export-audio] [--export-phase] 72 | ``` 73 | 74 | This will automatically generate a sweep test from 20Hz to Nyquist and plot its spectrogram. 75 | This mode will not read the `FREQS` variable. 76 | I suggest a duration of 5s to have a good enough resolution in the spectrogram. 77 | 78 | #### PSD 79 | For the psd mode use the following options : 80 | ```bash 81 | python python/main.py psd [--export-dir EXPORT_DIR] [--export-audio] [--export-phase] 82 | ``` 83 | 84 | This will use a matplotlib graph to display the psd values for each test, and a final 85 | graph with all the waveforms on the same graphs. 86 | 87 | **This mode requires `FREQS` to contains a single value** 88 | 89 | # What's next 90 | As mentioned above, this is an experimentation repo, not a tool designed for 91 | advanced use or anything like it. 92 | 93 | I don't plan to make modifications to make it a user-friendly demo tool. 94 | However I'm open to suggestions in order to help further researchs such as : 95 | - Improvements on the argparser to allow passing frequencies and/or other parameters 96 | - ~~Metrics improvements/fixes~~ DONE 97 | - Improvement/Changes in the algorithm 98 | 99 | 100 | If you wan't to discuss about it you can open an issue or you can find me on : 101 | - [Discord](https://discordapp.com/users/Groumpf#2353) 102 | - [Twitter](https://twitter.com/Groumpf_) 103 | - [Mastodon](https://piaille.fr/@groumpf) 104 | -------------------------------------------------------------------------------- /matlab/AAIIR_demo.m: -------------------------------------------------------------------------------- 1 | % -----------------------------------------------------------------------% 2 | % AA-IIR WAVEFORM GENERATION 3 | % Authors: L. Gabrielli, P.P. La Pastina, S. D'Angelo - 2021-2022 4 | % 5 | % Matlab implementation of a wavetable oscillator with AA-IIR 6 | % Two waveforms have been implemented: SAW and ESCALATION (See paper) 7 | % -----------------------------------------------------------------------% 8 | 9 | pkg load signal 10 | 11 | %% DEFINES 12 | 13 | Fs = 44100; 14 | % waveform = "ESCALATION"; 15 | waveform = "WAVETABLE_SAW"; 16 | % waveform = "SAW"; 17 | f0 = 1000; 18 | 19 | 20 | %% BENCHMARK FUNCTION 21 | 22 | function runbenchmark(Fs,f0,waveform,stopbdB,cutoff,order,type) 23 | 24 | duration = 1.0; % seconds 25 | time = linspace(0,duration,Fs*duration); 26 | test_ = zeros(length(time)); 27 | test_(441) = -1.0; 28 | 29 | if strcmp(type,'cheby2') 30 | stopbF = cutoff; 31 | [y, iirba] = AAIIROscCheby(time, stopbdB, stopbF, order, Fs, f0, waveform); 32 | elseif strcmp(type,'butter') 33 | [y, iirba] = AAIIROscButter(time, cutoff, order, Fs, f0, waveform); 34 | elseif strcmp(type,'ovs') 35 | y = OVSTrivial(time, f0, Fs, order, waveform); 36 | elseif strcmp(type,'trivial') 37 | y = OVSTrivial(time, f0, Fs, 1, waveform); 38 | else 39 | error('wrong method'); 40 | end 41 | 42 | audiowrite("octave_cheby_test.wav", y*0.80, Fs, 'BitsPerSample',32) 43 | 44 | % figure, plot(y(1:100)); 45 | figure, plot(time(1:1100), y(1:1100)); 46 | % figure, plot(I_sums(1:100)); 47 | ylim([-1 1]); 48 | legend(type); 49 | % plot(time,10*log10(y)); 50 | [pxx, f] = pwelch(y,4096,[],[],Fs); 51 | loglog(f, pxx); 52 | plot(f, log2(pxx)); 53 | grid on; 54 | 55 | end 56 | 57 | 58 | %% DSP METHODS 59 | 60 | function [y,iirba] = AAIIROscButter(n, cutoff, order, Fs, f0, waveform) 61 | 62 | Fc = cutoff; % [Hz] 63 | Fcrads = 2*pi*Fc / Fs; % [rad/sample] 64 | 65 | [z,p,k] = butter(order, Fcrads, 's'); 66 | [b,a] = zp2tf(z,p,k); 67 | [r,p,k] = residue(b,a); 68 | 69 | L = length(n); 70 | x = (1:L)*f0/Fs; % vector of x, evenly spaced 71 | 72 | y_aa = 0*x; 73 | for o = 1:2:order % poles are in conjg pairs, must take only one for each 74 | ri = r(o); 75 | zi = p(o); 76 | y_aa = y_aa + AA_osc_cplx(x, ri, zi, Fs, waveform); 77 | end 78 | 79 | y = y_aa; 80 | iirba = [a, b]; 81 | 82 | end 83 | 84 | 85 | function [y,iirba] = AAIIROscCheby(n, stbAtt, stbFreq, order, Fs, f0, waveform) 86 | 87 | x = 2 * f0 * mod(n,1/f0) - 1; 88 | 89 | Fc = stbFreq; 90 | Fcrads = 2*pi*Fc / Fs; 91 | 92 | [z,p,k] = cheby2(order, stbAtt, Fcrads, 's'); 93 | [b,a] = zp2tf(z,p,k); 94 | [r,p,k] = residue(b,a); 95 | 96 | L = length(x); 97 | x = (1:L)*f0/Fs; 98 | 99 | y_aa = 0*x; 100 | for o = 1:2:order % poles are in conjg pairs, must take only one for each 101 | ri = r(o); 102 | zi = p(o); % the other is conjugated 103 | y_aa = y_aa + AA_osc_cplx(x, ri, zi, Fs, waveform); 104 | end 105 | 106 | y = y_aa; 107 | iirba = [a; b]; 108 | 109 | end 110 | 111 | 112 | function y = OVSTrivial(n, f0, Fs, order, waveform) 113 | 114 | duration = max(n); 115 | nupsmpl = linspace(0,duration,Fs*order*duration); 116 | trivupsmpl = 2 * f0 * mod(nupsmpl,1/f0) - 1; 117 | 118 | if strcmp(waveform, 'SAW') 119 | yi = trivupsmpl; 120 | elseif strcmp(waveform, 'ESCALATION') 121 | % linearly interpolated read 122 | [~,~,~,wt] = generateEscalationII_w3(); 123 | yi = linint_wt_read(trivupsmpl, wt); 124 | end 125 | 126 | y = decimate(yi,order); 127 | 128 | end 129 | 130 | 131 | function y = linint_wt_read(x, wt) 132 | 133 | N = length(wt); 134 | dur = length(x); 135 | 136 | % x is in range -1:1 but must be in range 1:N to read the wt 137 | X = (x * (N-1)/2) + (N-1)/2 + 1; 138 | 139 | y = 0*x; 140 | for i = 1:dur 141 | intx = floor(X(i)); 142 | while intx > N 143 | intx = intx - N; 144 | end 145 | intx_1 = intx+1; 146 | while intx_1 > N 147 | intx_1 = intx_1 - N; 148 | end 149 | frac = X(i) - intx; 150 | y(i) = (1-frac)*wt(intx) + frac*wt(intx_1); 151 | end 152 | 153 | end 154 | 155 | 156 | %% RUN TRIVIAL WAVEFORM GENERATION 157 | 158 | % runbenchmark(Fs,f0,waveform,'','','','trivial'); 159 | 160 | % %% RUN 8x OVERSAMPLING 161 | 162 | % runbenchmark(Fs,f0,waveform,'','',8,'ovs'); 163 | 164 | %% AAIIR: BUTTERWORTH ORDER 2 (mild antialiasing) 165 | 166 | stopbF = 0.45 * Fs; 167 | order = 2; 168 | % runbenchmark(Fs,f0,waveform,'',stopbF,order,'butter'); 169 | 170 | %% AAIIR: CHEBYSHEV ORDER 10 (best antialiasing) 171 | 172 | stopbdB = 60; 173 | stopbF = 0.61 * Fs; 174 | order = 10; 175 | runbenchmark(Fs,f0,waveform,stopbdB,stopbF,order,'cheby2'); 176 | 177 | while waitforbuttonpress != 1 178 | continue; 179 | end -------------------------------------------------------------------------------- /matlab/AA_osc_cplx.m: -------------------------------------------------------------------------------- 1 | function [y] = AA_osc_cplx(x, resid, polo, fs, wave) 2 | 3 | % waveform info 4 | if strcmp(wave,'ESCALATION') 5 | [X,m,q,~] = generateEscalationII_w3(); 6 | elseif strcmp(wave,'WAVETABLE_SAW') 7 | [X,m,q, ~] = generateWavetableSaw(); 8 | else 9 | % SAW 10 | X = [0,1]; 11 | m = [2]; 12 | q = [-1]; 13 | end 14 | 15 | %%% 16 | % X | list[scalar] E [0;1] : normalized sample position relative to the waveform length 17 | % k | scalar : number of samples inside the waveform 18 | % 19 | % x | list[scalar] : of phase offsets 20 | % x_red | scalar : phase offset modulo T (which is 1 but whatever) 21 | % 22 | % j | scalar : idx of an X sample 23 | % j_red | scalar : j kinda modulo k 24 | % 25 | % y | list[scalar] : result 26 | % 27 | %%% 28 | 29 | 30 | T = X(end); % period 31 | k = length(m); % nr segments 32 | 33 | % compute diff 34 | for j = 1:k - 1 35 | m_diff(j) = m(j + 1) - m(j); 36 | q_diff(j) = q(j + 1) - q(j); 37 | end 38 | m_diff(k) = m(1) - m(k); 39 | q_diff(k) = q(1) - q(k) - m(1) * T; 40 | 41 | % filter 42 | % beta is alpha inside the paper 43 | B = resid; 44 | beta = polo; 45 | expbeta = exp(beta); 46 | 47 | % initial conditions 48 | x_vz1 = 0; 49 | y_hat_vz1 = 0; 50 | x_diff_vz1 = 0; 51 | 52 | % index j corresponding to initial sample x_vz1 53 | x_red = mod(x_vz1, T); 54 | j_red = binary_search_down(X, x_red, 1, length(X)); 55 | j = k * floor(x_vz1/T) + j_red - 1; % seems to be an index, not a complex 56 | % j seem to be the index of x, the phase offset 57 | 58 | % Process 59 | for n = 2:length(x) 60 | 61 | % x_vz1 is the precedent value of x 62 | x_diff = x(n) - x_vz1; 63 | j_vz1 = j; 64 | 65 | if ((x_diff >= 0 && x_diff_vz1 >= 0) || (x_diff < 0 && x_diff_vz1 <= 0)) 66 | % If on the same slope than previous iteration 67 | j_vz1 = j + sign(x_red - X(j_red)); 68 | % +1 or -1 or +0 depending on the sign of x_red - X(j_red) 69 | end 70 | 71 | x_red = mod(x(n), T); 72 | 73 | if (x_diff >= 0) 74 | j_red = binary_search_down(X, x_red, 1, length(X)); 75 | j = k * floor(x(n)/T) + j_red - 1; 76 | j_min = j_vz1; 77 | j_max = j; 78 | else 79 | j_red = binary_search_up(X, x_red, 1, length(X)); 80 | j = k * floor(x(n)/T) + j_red - 1; 81 | j_min = j; 82 | j_max = j_vz1; 83 | end 84 | 85 | j_min_bar = mod_bar(j_min, k); 86 | j_max_p_bar = mod_bar(j_max + 1, k); 87 | 88 | if (x_diff >= 0) 89 | I = expbeta \ 90 | * (m(j_min_bar) * x_diff + beta * (m(j_min_bar) * (x_vz1 - T * floor((j_min - 1)/k)) + q(j_min_bar)))\ 91 | - m(j_max_p_bar) * x_diff \ 92 | - beta * (m(j_max_p_bar) * (x(n) - T * floor(j_max/k)) + q(j_max_p_bar)); 93 | else 94 | I = expbeta \ 95 | * (m(j_max_p_bar) * x_diff + beta * (m(j_max_p_bar) * (x_vz1 - T * floor(j_max/k)) + q(j_max_p_bar))) \ 96 | - m(j_min_bar) * x_diff \ 97 | - beta * (m(j_min_bar) * (x(n) - T * floor((j_min - 1)/k)) + q(j_min_bar)); 98 | end 99 | 100 | I_sum = 0; 101 | s_parts = zeros(1, j_max - j_min); 102 | for l = j_min:j_max 103 | l_bar = mod_bar(l, k); 104 | I_sum = I_sum \ 105 | + exp(beta * (x(n) - X(l_bar + 1) - T * floor((l - 1)/k))/x_diff) \ 106 | * (beta * q_diff(l_bar) + m_diff(l_bar) * (x_diff + beta * X(l_bar + 1))); 107 | end 108 | 109 | I = (I + sign(x_diff) * I_sum)/beta^2; 110 | 111 | % See formula n°10 112 | y_hat = expbeta * y_hat_vz1 + 2 * B * I; 113 | % We take the real part of y 114 | y(n) = real(y_hat); 115 | 116 | x_vz1 = x(n); 117 | y_hat_vz1 = y_hat; 118 | x_diff_vz1 = x_diff; 119 | 120 | end 121 | 122 | endfunction 123 | 124 | % i tel que x_i < x_0 < x_(i+1) && j_min <= i <= j_max 125 | % x_i_m1 < x_m1 < x_i 126 | function y = binary_search_down(x, x0, j_min, j_max) 127 | % index of last number in ordered vec x <= x0, among those between j_min, j_max. 128 | % if x0 < x(1), return 0. 129 | 130 | if (x0 < x(1)) 131 | y = 0; 132 | elseif (x0 >= x(j_max)) 133 | y = j_max; 134 | else 135 | i_mid = floor((j_min + j_max)/2); 136 | if (x0 < x(i_mid)) 137 | j_max = i_mid; 138 | elseif (x0 == x(i_mid)) 139 | y = i_mid; 140 | return 141 | else 142 | j_min = i_mid; 143 | end 144 | if (j_max - j_min > 1) 145 | y = binary_search_down(x, x0, j_min, j_max); 146 | else 147 | y = j_min; 148 | end 149 | end 150 | 151 | endfunction 152 | 153 | 154 | function y = binary_search_up(x, x0, j_min, j_max) 155 | 156 | if (x0 > x(end)) 157 | y = length(x) + 1; 158 | elseif (x0 <= x(1)) 159 | y = 1; 160 | else 161 | i_mid = floor((j_min + j_max)/2); 162 | if (x0 < x(i_mid)) 163 | j_max = i_mid; 164 | elseif (x0 == x(i_mid)) 165 | y = i_mid; 166 | return 167 | else 168 | j_min = i_mid; 169 | end 170 | if (j_max - j_min > 1) 171 | y = binary_search_up(x, x0, j_min, j_max); 172 | else 173 | y = j_max; 174 | end 175 | end 176 | 177 | endfunction 178 | 179 | % The weird mod that doesn't go to zero 180 | % defined after formula (18) 181 | function y = mod_bar(x, k) 182 | % return mod(x, k), if not 0 else return k 183 | m = mod(x, k); 184 | y = m + k * (1 - sign(m)); 185 | endfunction 186 | -------------------------------------------------------------------------------- /matlab/generateEscalationII_w3.m: -------------------------------------------------------------------------------- 1 | % Generate one of Massive wavetables: the third in M2-Basic/EscalationII.wav 2 | % these wav files contain one or more 2048 point tables. This is the third. 3 | 4 | function [X,m,q,wt] = generateEscalationII_w3() 5 | 6 | tabL = 2048; 7 | 8 | % data points 9 | X = [0 1 2 3 4 5 6 7 8]/8; 10 | m = [ 1 -1 1 -1 -1 1 -1 1]*2; 11 | q = [0 0 0 0 2 -2 2 -2]; 12 | 13 | % generate plot 14 | segmL = tabL / (length(X)-1); 15 | y = zeros(1,tabL); 16 | for i = 1:length(X)-1 17 | x = linspace(X(i),X(i+1),segmL); 18 | y(1+(segmL*(i-1)):segmL*(i)) = m(i)*x + q(i); 19 | end 20 | wt = y; 21 | %figure, plot(y) 22 | 23 | end -------------------------------------------------------------------------------- /matlab/generateWavetableSaw.m: -------------------------------------------------------------------------------- 1 | 2 | function [X, m, q, wt] = generateWavetableSaw() 3 | function m = compute_m(x0, x1, y0, y1) 4 | m = (y1 - y0) / (x1 - x0); 5 | end 6 | 7 | function q = compute_q(x0, x1, y0, y1) 8 | q = (y0 * (x1 - x0) - x0 * (y1 - y0)) / (x1 - x0); 9 | end 10 | 11 | FRAMES = 2048; 12 | X = linspace(0.0, 1.0, FRAMES + 1); 13 | wt = zeros(1,FRAMES); 14 | m = zeros(1,FRAMES); 15 | q = zeros(1,FRAMES); 16 | 17 | steps = 1.0/FRAMES; 18 | % phase = 0.5 + steps; 19 | phase = 0; 20 | 21 | for i = 2:FRAMES 22 | wt(i) = 2.0 * phase - 1.0; 23 | 24 | m(i-1) = compute_m(X(i-1), X(i), wt(i-1), wt(i)); 25 | q(i-1) = compute_q(X(i-1), X(i), wt(i-1), wt(i)); 26 | 27 | phase = mod((phase + steps), 1.0); 28 | end 29 | 30 | m(end) = compute_m(X(FRAMES - 1), X(FRAMES), wt(FRAMES - 1), wt(FRAMES)); 31 | q(end) = compute_q(X(FRAMES - 1), X(FRAMES), wt(FRAMES - 1), wt(FRAMES)); 32 | end -------------------------------------------------------------------------------- /matlab/octave-workspace: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarsc/ADAA_wavetable/60b905eeda5142af665369e7d448b8fd437c95bd/matlab/octave-workspace -------------------------------------------------------------------------------- /python/bl_waveform.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | from numpy import asarray, zeros, pi, sin, cos, amax, diff, arange, outer 3 | import numpy as np 4 | from numba import njit 5 | 6 | 7 | # see https://gist.github.com/endolith/407991 8 | @njit 9 | def bl_sawtooth(x, play_freq): # , width=1 10 | """ 11 | Return a periodic band-limited sawtooth wave with 12 | period 2*pi which is falling from 0 to 2*pi and rising at 13 | 2*pi (opposite phase relative to a sin) 14 | Produces the same phase and amplitude as scipy.signal.sawtooth. 15 | Examples 16 | -------- 17 | >>> t = linspace(0, 1, num = 1000, endpoint = False) 18 | >>> f = 5 # Hz 19 | >>> plot(bl_sawtooth(2 * pi * f * t)) 20 | """ 21 | t = asarray(2 * pi * play_freq * x) 22 | 23 | if abs((t[-1] - t[-2]) - (t[1] - t[0])) > 0.0000001: 24 | raise ValueError("Sampling frequency must be constant") 25 | 26 | # if t.dtype.char in ['fFdD']: 27 | # ytype = t.dtype.char 28 | # else: 29 | # ytype = 'd' 30 | y = zeros(t.shape, dtype=np.float32) 31 | 32 | # Get sampling frequency from timebase 33 | fs = 1 / (t[1] - t[0]) 34 | # fs = 1 / amax(diff(t)) 35 | 36 | # Sum all multiple sine waves up to the Nyquist frequency 37 | 38 | # TODO: Maybe choose between these based on number of harmonics? 39 | 40 | # Slower, uses less memory 41 | for h in range(1, int(fs * pi) + 1): 42 | y += 2 / pi * -sin(h * t) / h 43 | 44 | return y 45 | -------------------------------------------------------------------------------- /python/decimator.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Decimator9: 5 | def __init__(self): 6 | self.__h0: float = 8192 / 16384.0 7 | self.__h1: float = 5042 / 16384.0 8 | self.__h3: float = -1277 / 16384.0 9 | self.__h5: float = 429 / 16384.0 10 | self.__h7: float = -116 / 16384.0 11 | self.__h9: float = 18 / 16384.0 12 | self.__r1: float = 0.0 13 | self.__r2: float = 0.0 14 | self.__r3: float = 0.0 15 | self.__r4: float = 0.0 16 | self.__r5: float = 0.0 17 | self.__r6: float = 0.0 18 | self.__r7: float = 0.0 19 | self.__r8: float = 0.0 20 | self.__r9: float = 0.0 21 | 22 | def process(self, x0: float, x1: float) -> float: 23 | h9x0 = self.__h9 * x0 24 | h7x0 = self.__h7 * x0 25 | h5x0 = self.__h5 * x0 26 | h3x0 = self.__h3 * x0 27 | h1x0 = self.__h1 * x0 28 | self.__r10 = self.__r9 + h9x0 29 | self.__r9 = self.__r8 + h7x0 30 | self.__r8 = self.__r7 + h5x0 31 | self.__r7 = self.__r6 + h3x0 32 | self.__r6 = self.__r5 + h1x0 33 | self.__r5 = self.__r4 + h1x0 + self.__h0 * x1 34 | self.__r4 = self.__r3 + h3x0 35 | self.__r3 = self.__r2 + h5x0 36 | self.__r2 = self.__r1 + h7x0 37 | self.__r1 = h9x0 38 | return self.__r10 39 | 40 | 41 | class Decimator17: 42 | def __init__(self): 43 | self.__h0: float = 0.5 44 | self.__h1: float = 0.314356238 45 | self.__h3: float = -0.0947515890 46 | self.__h5: float = 0.0463142134 47 | self.__h7: float = -0.0240881704 48 | self.__h9: float = 0.0120250406 49 | self.__h11: float = -0.00543170841 50 | self.__h13: float = 0.00207426259 51 | self.__h15: float = -0.000572688237 52 | self.__h17: float = 5.18944944e-005 53 | self.__r1: float = 0.0 54 | self.__r2: float = 0.0 55 | self.__r3: float = 0.0 56 | self.__r4: float = 0.0 57 | self.__r5: float = 0.0 58 | self.__r6: float = 0.0 59 | self.__r7: float = 0.0 60 | self.__r8: float = 0.0 61 | self.__r9: float = 0.0 62 | self.__r10: float = 0.0 63 | self.__r11: float = 0.0 64 | self.__r12: float = 0.0 65 | self.__r13: float = 0.0 66 | self.__r14: float = 0.0 67 | self.__r15: float = 0.0 68 | self.__r16: float = 0.0 69 | self.__r17: float = 0.0 70 | 71 | def process(self, x0: float, x1: float) -> float: 72 | h17x0 = self.__h17 * x0 73 | h15x0 = self.__h15 * x0 74 | h13x0 = self.__h13 * x0 75 | h11x0 = self.__h11 * x0 76 | h9x0 = self.__h9 * x0 77 | h7x0 = self.__h7 * x0 78 | h5x0 = self.__h5 * x0 79 | h3x0 = self.__h3 * x0 80 | h1x0 = self.__h1 * x0 81 | self.__r18 = self.__r17 + h17x0 82 | self.__r17 = self.__r16 + h15x0 83 | self.__r16 = self.__r15 + h13x0 84 | self.__r15 = self.__r14 + h11x0 85 | self.__r14 = self.__r13 + h9x0 86 | self.__r13 = self.__r12 + h7x0 87 | self.__r12 = self.__r11 + h5x0 88 | self.__r11 = self.__r10 + h3x0 89 | self.__r10 = self.__r9 + h1x0 90 | self.__r9 = self.__r8 + h1x0 + self.__h0 * x1 91 | self.__r8 = self.__r7 + h3x0 92 | self.__r7 = self.__r6 + h5x0 93 | self.__r6 = self.__r5 + h7x0 94 | self.__r5 = self.__r4 + h9x0 95 | self.__r4 = self.__r3 + h11x0 96 | self.__r3 = self.__r2 + h13x0 97 | self.__r2 = self.__r1 + h15x0 98 | self.__r1 = h17x0 99 | return self.__r18 100 | -------------------------------------------------------------------------------- /python/legacy.py: -------------------------------------------------------------------------------- 1 | from numba import njit 2 | import numpy as np 3 | from math import exp, floor, ceil 4 | from cmath import exp as cexp 5 | from typing import Tuple, List, Dict 6 | from mipmap import * 7 | 8 | 9 | @njit 10 | def binary_search_down(x: np.ndarray, x0: float, j_min: int, j_max: int) -> int: 11 | """ 12 | return i as x_i < x_0 < x_(i+1) && j_min <= i <= j_max (or j_max - 1) 13 | """ 14 | if x0 < x[0]: 15 | return -1 # Should it be -1 ? 0 in matlab so it's weird 16 | elif x0 >= x[j_max]: 17 | return j_max - 1 18 | else: 19 | i_mid = floor((j_min + j_max) / 2) 20 | 21 | if x0 < x[i_mid]: 22 | j_max = i_mid 23 | elif x0 == x[i_mid]: 24 | return i_mid 25 | else: 26 | j_min = i_mid 27 | 28 | if j_max - j_min > 1: 29 | return binary_search_down(x, x0, j_min, j_max) 30 | else: 31 | return j_min 32 | 33 | 34 | @njit 35 | def binary_search_up(x: np.ndarray, x0: float, j_min: int, j_max: int): 36 | """ 37 | return i as x_i > x_0 > x_(i+1) && j_min <= i <= j_max 38 | 39 | FIXME: I think this function is bugged 40 | """ 41 | if x0 >= x[-1]: 42 | return x.shape[0] 43 | elif x0 <= x[0]: 44 | return 0 45 | else: 46 | i_mid = floor((j_min + j_max) / 2) 47 | 48 | if x0 < x[i_mid]: 49 | j_max = i_mid 50 | elif x0 == x[i_mid]: 51 | return i_mid 52 | 53 | if j_max - j_min > 1: 54 | return binary_search_up(x, x0, j_min, j_max) 55 | else: 56 | return j_max 57 | 58 | 59 | @njit 60 | def process_bi(x, B, beta: complex, X, m, q, m_diff, q_diff): 61 | """ 62 | Direct translation from the matlab algorithm to python. Be aware matlab arrays starts at 1 so I had to make 63 | a few changes 64 | 65 | 66 | This code contains A LOT of annotations with commented out stuff. This is because I want to have a written trace 67 | of the adaptation I had to make to write the simplified process_fwd() version. 68 | 69 | Notice that this code does support reverse playback whereas process_fwd() does not 70 | """ 71 | y = np.zeros(x.shape[0]) 72 | 73 | # Period - should be 1 74 | assert X[-1] == 1.0 75 | T = 1.0 76 | 77 | waveform_frames = m.shape[0] # aka k 78 | 79 | expbeta = cexp(beta) 80 | 81 | # # Testing 82 | # alt_j_max_p_red = 0 83 | # alt_j_min_red = 0 84 | 85 | # Initial condition 86 | prev_x = x[0] 87 | prev_cpx_y: complex = 0 88 | prev_x_diff = 0 89 | 90 | # Setting j indexs and some reduced values 91 | x_red = prev_x % 1.0 92 | # j_red = binary_search_down(X, x_red, 0, X.shape[0] - 1) 93 | j_red = floor(x_red * (X.shape[0] - 1)) 94 | j = waveform_frames * floor(prev_x / 1.0) + j_red - 1 95 | 96 | for n in range(1, x.shape[0]): 97 | # loop init 98 | x_diff = x[n] - prev_x 99 | # prev_x_red_bar = x_red + (x_red == 0.0) # To replace (prev_x - T * floor(j_min/ waveform_frames)) 100 | prev_j = j 101 | # prev_j_red = j_red % waveform_frames 102 | 103 | # TODO: No idea ? 104 | if (x_diff >= 0 and prev_x_diff >= 0) or (x_diff < 0 and prev_x_diff <= 0): 105 | # If on the same slop as the previous iteration 106 | prev_j = j + int(np.sign(x_red - X[j_red])) 107 | # prev_j_red = j_red + int(np.sign(x_red - X[j_red])) 108 | # Is used to avoid computing a new j_min using the binary search, because 109 | # if j_min == j_max then the I sum is zero so its corresponds to the case 110 | # where x_n and x_n+1 are in the same interval 111 | 112 | x_red = x[n] % 1.0 113 | 114 | # Should be differentiated upstream to avoid if on each sample 115 | if x_diff >= 0: 116 | # playback going forward 117 | # j_red = binary_search_down(X, x_red, 0, X.shape[0] - 1) 118 | j_red = floor(x_red * waveform_frames) 119 | 120 | j = waveform_frames * floor(x[n] / 1.0) + j_red - 1 121 | j_min = prev_j 122 | j_max = j 123 | else: 124 | # playback going backward 125 | # j_red = binary_search_up(X, x_red, 0, X.shape[0] - 1) 126 | j_red = ceil(x_red * waveform_frames) 127 | 128 | j = waveform_frames * floor(x[n] / 1.0) + j_red - 1 129 | j_min = j 130 | j_max = prev_j 131 | 132 | j_min_red = j_min % waveform_frames 133 | j_max_p_red = (j_max + 1) % waveform_frames 134 | 135 | # prev_x_red_bar = prev_x % 1.0 136 | # prev_x_red_bar += (prev_x_red_bar == 0.0) 137 | 138 | # Could be differentiated upstream to avoid if on each sample 139 | if x_diff >= 0: 140 | ## OG version 141 | I = ( 142 | expbeta 143 | * ( 144 | m[j_min_red] * x_diff 145 | + beta 146 | * ( 147 | m[j_min_red] * (prev_x - T * floor(j_min / waveform_frames)) 148 | + q[j_min_red] 149 | ) 150 | ) 151 | - m[j_max_p_red] * x_diff 152 | - beta 153 | * ( 154 | m[j_max_p_red] * (x[n] - T * floor((j_max + 1) / waveform_frames)) 155 | + q[j_max_p_red] 156 | ) 157 | ) 158 | 159 | ### j_min/j_max independant version 160 | # I = expbeta\ 161 | # * (m[j_min_red] * x_diff + beta * (m[j_min_red] * prev_x_red_bar + q[j_min_red]))\ 162 | # - m[j_max_p_red] * x_diff\ 163 | # - beta * (m[j_max_p_red] * x_red + q[j_max_p_red]) 164 | else: 165 | I = ( 166 | expbeta 167 | * ( 168 | m[j_max_p_red] * x_diff 169 | + beta 170 | * ( 171 | m[j_max_p_red] 172 | * (prev_x - T * floor((j_max + 1) / waveform_frames)) 173 | + q[j_max_p_red] 174 | ) 175 | ) 176 | - m[j_min_red] * x_diff 177 | - beta 178 | * ( 179 | m[j_min_red] * (x[n] - T * floor(j_min / waveform_frames)) 180 | + q[j_min_red] 181 | ) 182 | ) 183 | 184 | I_sum = 0 185 | 186 | if x_diff < 0 and j_min != -1 and j_min_red > j_max_p_red: 187 | cycle_offset = -1.0 188 | else: 189 | cycle_offset = 0.0 190 | 191 | for i in range(j_min, j_max + 1): # OG Version 192 | i_red = i % waveform_frames 193 | ref_bi = x_red + cycle_offset + (i_red > j_max_p_red) 194 | 195 | I_sum += cexp( 196 | beta * (x[n] - X[i_red + 1] - T * floor((i) / waveform_frames)) / x_diff 197 | ) * (beta * q_diff[i_red] + m_diff[i_red] * (x_diff + beta * X[i_red + 1])) 198 | 199 | I = (I + np.sign(x_diff) * I_sum) / (beta**2) 200 | 201 | # See formula (10) 202 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * I 203 | y[n] = y_cpx.real 204 | 205 | prev_x = x[n] 206 | prev_cpx_y = y_cpx 207 | prev_x_diff = x_diff 208 | 209 | return y 210 | 211 | 212 | @njit 213 | def process_fwd(x, B, beta: complex, X, m, q, m_diff, q_diff): 214 | """ 215 | This is a simplified version of the process method translated from matlab, more suited to real time use : 216 | 217 | - Assuming the playback will only goes forward (no reverse playing), I removed the conditionnal branching on x_diff 218 | 219 | - I replaced the formulas using ever-growing indexes and phase with equivalent ones using only "reduced" variables: 220 | 1. (prev_x - T * floor(j_min/ waveform_frames)) became prev_x_red_bar 221 | 2. (x[n] - T * floor((j_max+1)/waveform_frames)) is equivalent to x_red 222 | 3. (x[n] - T * floor((i)/waveform_frames)) became x_red_bar 223 | 224 | see process() for the original "translation" from matlab code 225 | """ 226 | y = np.zeros(x.shape[0]) 227 | 228 | # Period - should be 1 229 | assert X[-1] == 1.0 230 | 231 | waveform_frames = m.shape[0] # aka k 232 | 233 | expbeta = cexp(beta) 234 | 235 | # Initial condition 236 | prev_x = x[0] 237 | prev_cpx_y: complex = 0 238 | prev_x_diff = 0 239 | 240 | # Setting j indexs and some reduced values 241 | x_red = prev_x % 1.0 242 | # j_red = binary_search_down(X, x_red, 0, X.shape[0] - 1) 243 | j_red = floor(x_red * (X.shape[0] - 1)) 244 | 245 | for n in range(1, x.shape[0]): 246 | # loop init 247 | x_diff = x[n] - prev_x 248 | assert x_diff >= 0 249 | # prev_x_red_bar = x_red + (x_red == 0.0) # To replace (prev_x - T * floor(j_min/ waveform_frames)) 250 | prev_j_red = j_red % waveform_frames 251 | 252 | # TODO: No idea ? 253 | if (x_diff >= 0 and prev_x_diff >= 0) or (x_diff < 0 and prev_x_diff <= 0): 254 | # If on the same slop as the previous iteration 255 | prev_j_red = j_red + int(np.sign(x_red - X[j_red])) 256 | # Is used to avoid computing a new j_min using the binary search, because 257 | # if j_min == j_max then the I sum is zero so its corresponds to the case 258 | # where x_n and x_n+1 are in the same interval 259 | 260 | x_red = x[n] % 1.0 261 | 262 | # playback going forward 263 | # j_red = binary_search_down(X, x_red, 0, X.shape[0] - 1) 264 | j_red = floor(x_red * (X.shape[0] - 1)) 265 | j_max_p_red = j_red 266 | j_min_red = (prev_j_red - 1) % waveform_frames 267 | 268 | prev_x_red_bar = prev_x % 1.0 269 | prev_x_red_bar += prev_x_red_bar == 0.0 270 | 271 | I = ( 272 | expbeta 273 | * ( 274 | m[j_min_red] * x_diff 275 | + beta * (m[j_min_red] * prev_x_red_bar + q[j_min_red]) 276 | ) 277 | - m[j_max_p_red] * x_diff 278 | - beta * (m[j_max_p_red] * x_red + q[j_max_p_red]) 279 | ) 280 | 281 | I_sum = 0 282 | born_sup = j_max_p_red + waveform_frames * (j_min_red > j_max_p_red) 283 | for i in range(j_min_red, born_sup): 284 | i_red = i % waveform_frames 285 | x_red_bar = x[n] % 1.0 286 | x_red_bar = x_red_bar + (x_red_bar < X[i_red]) 287 | 288 | I_sum += cexp(beta * (x_red_bar - X[i_red + 1]) / x_diff) * ( 289 | beta * q_diff[i_red] + m_diff[i_red] * (x_diff + beta * X[i_red + 1]) 290 | ) 291 | 292 | I = (I + np.sign(x_diff) * I_sum) / (beta**2) 293 | 294 | # See formula (10) 295 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * I 296 | y[n] = y_cpx.real 297 | 298 | prev_x = x[n] 299 | prev_cpx_y = y_cpx 300 | prev_x_diff = x_diff 301 | 302 | return y 303 | 304 | 305 | @njit 306 | def process_fwd_mipmap_xfading( 307 | x, 308 | B, 309 | beta: complex, 310 | X_mipmap: List[np.ndarray[float]], 311 | m_mipmap: List[np.ndarray[float]], 312 | q_mipmap: List[np.ndarray[float]], 313 | m_diff_mipmap: List[np.ndarray[float]], 314 | q_diff_mipmap: List[np.ndarray[float]], 315 | mipmap_scale: np.ndarray[float], 316 | ): 317 | """ 318 | This is a simplified version of the process method translated from matlab, more suited to real time use : 319 | 320 | - Assuming the playback will only goes forward (no reverse playing), I removed the conditionnal branching on x_diff 321 | 322 | - I replaced the formulas using ever-growing indexes and phase with equivalent ones using only "reduced" variables: 323 | 1. (prev_x - T * floor(j_min/ waveform_frames)) became prev_x_red_bar 324 | 2. (x[n] - T * floor((j_max+1)/waveform_frames)) is equivalent to x_red 325 | 3. (x[n] - T * floor((i)/waveform_frames)) became x_red_bar 326 | 327 | see process() for the original "translation" from matlab code 328 | """ 329 | y = np.zeros(x.shape[0]) 330 | 331 | # Period - should be 1 332 | for phases in X_mipmap: 333 | assert phases[-1] == 1.0 334 | 335 | expbeta = cexp(beta) 336 | 337 | # Initial condition 338 | prev_x = x[0] 339 | prev_cpx_y: complex = 0 340 | 341 | # Setting j indexs and some reduced values 342 | x_red = prev_x % 1.0 343 | x_diff = x[1] - x[0] 344 | mipmap_xfade_idxs = find_mipmap_xfading_indexes(x_diff, mipmap_scale) 345 | prev_mipmap_idx = mipmap_xfade_idxs[0] 346 | # j_red = binary_search_down(X_mipmap[prev_mipmap_idx], x_red, 0, X_mipmap[prev_mipmap_idx].shape[0] - 1) 347 | waveform_frames = m_mipmap[prev_mipmap_idx].shape[0] 348 | j_red = floor(x_red * waveform_frames) 349 | 350 | for n in range(1, x.shape[0]): 351 | # loop init 352 | x_diff = x[n] - prev_x 353 | if x_diff <= 0: 354 | x_diff += 1.0 355 | assert x_diff >= 0 356 | 357 | mipmap_idx, weight, mipmap_idx_up, weight_up = find_mipmap_xfading_indexes( 358 | x_diff, mipmap_scale 359 | ) 360 | waveform_frames = m_mipmap[mipmap_idx].shape[0] # aka k 361 | prev_x_red_bar = x_red + ( 362 | x_red == 0.0 363 | ) # To replace (prev_x - T * floor(j_min/ waveform_frames)) 364 | 365 | if mipmap_idx != prev_mipmap_idx: 366 | if mipmap_idx == prev_mipmap_idx + 1: 367 | # Going up in frequencies 368 | j_red = j_red // 2 369 | else: 370 | # Going down in frequencies 371 | j_red = j_red * 2 + (X_mipmap[mipmap_idx][j_red * 2 + 1] < x_red) 372 | prev_j_red = j_red + int(np.sign(x_red - X_mipmap[mipmap_idx][j_red])) 373 | 374 | x_red = x[n] % 1.0 375 | 376 | # playback going forward 377 | j_red = floor(x_red * waveform_frames) 378 | 379 | j_max_p_red = j_red 380 | j_min_red = (prev_j_red - 1) % waveform_frames 381 | 382 | prev_x_red_bar = prev_x % 1.0 383 | prev_x_red_bar += prev_x_red_bar == 0.0 384 | 385 | I_crt = compute_I_fwd( 386 | m_mipmap[mipmap_idx], 387 | q_mipmap[mipmap_idx], 388 | m_diff_mipmap[mipmap_idx], 389 | q_diff_mipmap[mipmap_idx], 390 | X_mipmap[mipmap_idx], 391 | j_min_red, 392 | j_max_p_red, 393 | beta, 394 | expbeta, 395 | x_diff, 396 | prev_x_red_bar, 397 | x_red, 398 | ) 399 | 400 | if weight_up != 0.0: 401 | jmin_up = j_min_red // 2 402 | jmax_up = j_max_p_red // 2 403 | 404 | # # -- Only to make sure I didn't made a mistake in index computation 405 | # ref_max = binary_search_down(X_mipmap[mipmap_idx_up], x_red, 0, X_mipmap[mipmap_idx_up].shape[0] - 1) 406 | # prev_x_red = x[n-1] % 1.0 407 | # ref_prev_j_red = binary_search_down(X_mipmap[mipmap_idx_up], prev_x_red, 0, X_mipmap[mipmap_idx_up].shape[0] - 1) 408 | # ref_prev_j_red = ref_prev_j_red + int(np.sign(prev_x_red - X_mipmap[mipmap_idx_up][ref_prev_j_red])) 409 | # ref_min = (ref_prev_j_red -1 ) % m_mipmap[mipmap_idx_up].shape[0] 410 | # assert(jmin_up == ref_min) 411 | # assert(jmax_up == ref_max) 412 | # # --- 413 | 414 | I_up = compute_I_fwd( 415 | m_mipmap[mipmap_idx_up], 416 | q_mipmap[mipmap_idx_up], 417 | m_diff_mipmap[mipmap_idx_up], 418 | q_diff_mipmap[mipmap_idx_up], 419 | X_mipmap[mipmap_idx_up], 420 | jmin_up, 421 | jmax_up, 422 | beta, 423 | expbeta, 424 | x_diff, 425 | prev_x_red_bar, 426 | x_red, 427 | ) 428 | I_crt = I_crt * weight + weight_up * I_up 429 | 430 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * (I_crt / (beta**2)) 431 | # y_cpx: complex = expbeta * prev_cpx_y + 2 * B * I_crt 432 | y[n] = y_cpx.real 433 | 434 | prev_x = x[n] 435 | 436 | prev_cpx_y = y_cpx 437 | # prev_x_diff = x_diff # Not required for fwd 438 | prev_mipmap_idx = mipmap_idx 439 | 440 | return y 441 | 442 | 443 | @njit 444 | def compute_I_fwd( 445 | m, q, m_diff, q_diff, X, jmin, jmax, beta, expbeta, x_diff, prev_x_red_bar, x_red 446 | ) -> complex: 447 | frames = m.shape[0] 448 | I = ( 449 | expbeta * (m[jmin] * x_diff + beta * (m[jmin] * prev_x_red_bar + q[jmin])) 450 | - m[jmax] * x_diff 451 | - beta * (m[jmax] * x_red + q[jmax]) 452 | ) 453 | 454 | I_sum = 0 455 | born_sup = jmax + frames * (jmin > jmax) 456 | for i in range(jmin, born_sup): 457 | i_red = i % frames 458 | x_red_bar = x_red + (x_red < X[i_red]) 459 | 460 | I_sum += cexp(beta * (x_red_bar - X[i_red + 1]) / x_diff) * ( 461 | beta * q_diff[i_red] + m_diff[i_red] * (x_diff + beta * X[i_red + 1]) 462 | ) 463 | 464 | # return (I + np.sign(x_diff) * I_sum) / (beta**2) 465 | return I + I_sum 466 | 467 | 468 | @njit 469 | def process_fwd_mipmap( 470 | x, 471 | B, 472 | beta: complex, 473 | X_mipmap: List[np.ndarray[float]], 474 | m_mipmap: List[np.ndarray[float]], 475 | q_mipmap: List[np.ndarray[float]], 476 | m_diff_mipmap: List[np.ndarray[float]], 477 | q_diff_mipmap: List[np.ndarray[float]], 478 | mipmap_scale: np.ndarray[float], 479 | ): 480 | """ 481 | This is a simplified version of the process method translated from matlab, more suited to real time use : 482 | 483 | - Assuming the playback will only goes forward (no reverse playing), I removed the conditionnal branching on x_diff 484 | 485 | - I replaced the formulas using ever-growing indexes and phase with equivalent ones using only "reduced" variables: 486 | 1. (prev_x - T * floor(j_min/ waveform_frames)) became prev_x_red_bar 487 | 2. (x[n] - T * floor((j_max+1)/waveform_frames)) is equivalent to x_red 488 | 3. (x[n] - T * floor((i)/waveform_frames)) became x_red_bar 489 | 490 | see process() for the original "translation" from matlab code 491 | """ 492 | y = np.zeros(x.shape[0]) 493 | 494 | # Period - should be 1 495 | # assert(all(phases[-1] == 1.0 for phases in X)) 496 | for phases in X_mipmap: 497 | assert phases[-1] == 1.0 498 | 499 | # waveform_frames = m.shape[0] # aka k 500 | 501 | expbeta = cexp(beta) 502 | 503 | # Initial condition 504 | prev_x = x[0] 505 | prev_cpx_y: complex = 0 506 | prev_x_diff = 0 507 | 508 | # Setting j indexs and some reduced values 509 | x_red = prev_x % 1.0 510 | x_diff = x[1] - x[0] 511 | prev_mipmap_idx = find_mipmap_index(x_diff, mipmap_scale) 512 | # j_red = binary_search_down(X_mipmap[prev_mipmap_idx], x_red, 0, X_mipmap[prev_mipmap_idx].shape[0] - 1) 513 | j_red = floor(x_red * X_mipmap[prev_mipmap_idx].shape[0] - 1) 514 | 515 | for n in range(1, x.shape[0]): 516 | # loop init 517 | x_diff = x[n] - prev_x 518 | if x_diff <= 0: 519 | x_diff += 1.0 520 | assert x_diff >= 0 521 | mipmap_idx = find_mipmap_index(x_diff, mipmap_scale) 522 | waveform_frames = m_mipmap[mipmap_idx].shape[0] # aka k 523 | prev_x_red_bar = x_red + ( 524 | x_red == 0.0 525 | ) # To replace (prev_x - T * floor(j_min/ waveform_frames)) 526 | 527 | if mipmap_idx == prev_mipmap_idx: 528 | prev_j_red = j_red + int(np.sign(x_red - X_mipmap[mipmap_idx][j_red])) 529 | else: 530 | # j_red = binary_search_down(X_mipmap[mipmap_idx], x_red, 0, X_mipmap[mipmap_idx].shape[0] - 1) 531 | j_red = floor(x_red * X_mipmap[mipmap_idx].shape[0] - 1) 532 | prev_j_red = j_red + int(np.sign(x_red - X_mipmap[mipmap_idx][j_red])) 533 | 534 | x_red = x[n] % 1.0 535 | 536 | # playback going forward 537 | # j_red = binary_search_down(X_mipmap[mipmap_idx], x_red, 0, X_mipmap[mipmap_idx].shape[0] - 1) 538 | j_red = floor(x_red * X_mipmap[mipmap_idx].shape[0] - 1) 539 | j_max_p_red = j_red 540 | j_min_red = (prev_j_red - 1) % waveform_frames 541 | 542 | prev_x_red_bar = prev_x % 1.0 543 | prev_x_red_bar += prev_x_red_bar == 0.0 544 | 545 | I = ( 546 | expbeta 547 | * ( 548 | m_mipmap[mipmap_idx][j_min_red] * x_diff 549 | + beta 550 | * ( 551 | m_mipmap[mipmap_idx][j_min_red] * prev_x_red_bar 552 | + q_mipmap[mipmap_idx][j_min_red] 553 | ) 554 | ) 555 | - m_mipmap[mipmap_idx][j_max_p_red] * x_diff 556 | - beta 557 | * ( 558 | m_mipmap[mipmap_idx][j_max_p_red] * x_red 559 | + q_mipmap[mipmap_idx][j_max_p_red] 560 | ) 561 | ) 562 | 563 | I_sum = 0 564 | born_sup = j_max_p_red + waveform_frames * (j_min_red > j_max_p_red) 565 | for i in range(j_min_red, born_sup): 566 | i_red = i % waveform_frames 567 | x_red_bar = x[n] % 1.0 568 | x_red_bar = x_red_bar + (x_red_bar < X_mipmap[mipmap_idx][i_red]) 569 | 570 | I_sum += cexp( 571 | beta * (x_red_bar - X_mipmap[mipmap_idx][i_red + 1]) / x_diff 572 | ) * ( 573 | beta * q_diff_mipmap[mipmap_idx][i_red] 574 | + m_diff_mipmap[mipmap_idx][i_red] 575 | * (x_diff + beta * X_mipmap[mipmap_idx][i_red + 1]) 576 | ) 577 | 578 | I = (I + np.sign(x_diff) * I_sum) / (beta**2) 579 | 580 | # See formula (10) 581 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * I 582 | y[n] = y_cpx.real 583 | # assert(abs(y[n]) < 3.0) 584 | 585 | prev_x = x[n] 586 | 587 | prev_cpx_y = y_cpx 588 | prev_x_diff = x_diff 589 | prev_mipmap_idx = mipmap_idx 590 | 591 | return y 592 | 593 | 594 | @njit 595 | def process_naive_hermite(waveform, x_values): 596 | """ 597 | Hermite interpolation algorithm 598 | 599 | Based on ADC21 talk by Matt Tytel, who based himself 600 | on implementation by Laurent de Soras 601 | 602 | https://www.youtube.com/watch?v=qlinVx60778 603 | """ 604 | 605 | y = np.zeros(x_values.shape[0]) 606 | waveform_len = waveform.shape[0] 607 | 608 | for i, x in enumerate(x_values): 609 | x_red = x % 1.0 610 | 611 | relative_idx = x_red * waveform_len 612 | idx_0 = (floor(relative_idx) - 1) % waveform_len 613 | idx_1 = floor(relative_idx) 614 | idx_2 = (floor(relative_idx) + 1) % waveform_len 615 | idx_3 = (floor(relative_idx) + 2) % waveform_len 616 | 617 | sample_offset = relative_idx - idx_1 618 | 619 | slope_0 = (waveform[idx_2] - waveform[idx_0]) * 0.5 620 | slope_1 = (waveform[idx_3] - waveform[idx_1]) * 0.5 621 | 622 | v = waveform[idx_1] - waveform[idx_2] 623 | w = slope_0 + v 624 | a = w + v + slope_1 625 | b_neg = w + a 626 | stage_1 = a * sample_offset - b_neg 627 | stage_2 = stage_1 * sample_offset + slope_0 628 | y[i] = stage_2 * sample_offset + waveform[idx_1] 629 | # assert(y[i] != np.NaN) 630 | 631 | return y 632 | -------------------------------------------------------------------------------- /python/main.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import numpy as np 3 | from math import exp, floor, ceil 4 | from cmath import exp as cexp 5 | from scipy.signal import ( 6 | butter, 7 | cheby2, 8 | residue, 9 | zpk2tf, 10 | decimate, 11 | ) 12 | import matplotlib.pyplot as plt 13 | import matplotlib 14 | import soundfile as sf 15 | import csv 16 | from pathlib import Path 17 | import argparse 18 | from dataclasses import dataclass 19 | from multiprocessing import Pool 20 | import logging 21 | from numba import njit 22 | import os 23 | from typing import Tuple, List, Dict 24 | 25 | from bl_waveform import bl_sawtooth 26 | from decimator import Decimator17, Decimator9 27 | from waveform import FileWaveform, NaiveWaveform 28 | from mipmap import * 29 | 30 | from metrics import compute_harmonic_factors 31 | import audioaliasingmetrics as aam 32 | 33 | logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s") 34 | 35 | SAMPLERATE = 44100 36 | BUTTERWORTH_CTF = 0.45 * SAMPLERATE 37 | CHEBY_CTF = 0.61 * SAMPLERATE 38 | CROSSFADE_S = 0.005 39 | # Watch out for ram usage 40 | DURATION_S = 1.0 41 | # Watch out for ram usage 42 | NUM_PROCESS = min(os.cpu_count(), 20) 43 | 44 | matplotlib.use("TkAgg") 45 | 46 | 47 | @dataclass 48 | class MipMapAdaaCache: 49 | m_mipmap: List[np.ndarray[float]] 50 | q_mipmap: List[np.ndarray[float]] 51 | m_diff_mipmap: List[np.ndarray[float]] 52 | q_diff_mipmap: List[np.ndarray[float]] 53 | mipmap_scale: np.ndarray[float] 54 | 55 | 56 | @dataclass 57 | class AdaaCache: 58 | m: np.ndarray[float] 59 | q: np.ndarray[float] 60 | m_diff: np.ndarray[float] 61 | q_diff: np.ndarray[float] 62 | 63 | 64 | @dataclass 65 | class NaiveCache: 66 | waveform: np.ndarray[float] 67 | 68 | 69 | @njit 70 | def noteToFreq(note: int) -> float: 71 | a = 440 # frequency of A (coomon value is 440Hz) 72 | return (a / 32) * (2 ** ((note - 9) / 12)) 73 | 74 | 75 | def butter_coeffs( 76 | order, ctf, samplerate 77 | ) -> Tuple[np.ndarray[complex], np.ndarray[complex]]: 78 | """ 79 | Computes butterworth filter coeffs like in the matlab code 80 | """ 81 | ang_freq = 2 * np.pi * ctf / samplerate 82 | (z, p, k) = butter(order, ang_freq, output="zpk", analog=True) 83 | (b, a) = zpk2tf(z, p, k) 84 | (r, p, _) = residue(b, a) 85 | return (r, p, (b, a)) 86 | 87 | 88 | def cheby_coeffs( 89 | order, ctf, attn, samplerate 90 | ) -> Tuple[np.ndarray[complex], np.ndarray[complex]]: 91 | """ 92 | Computes chebyshev type 2 filter coeffs like in the matlab code 93 | """ 94 | ang_freq = 2 * np.pi * ctf / samplerate 95 | (z, p, k) = cheby2(order, attn, ang_freq, output="zpk", analog=True) 96 | (b, a) = zpk2tf(z, p, k) 97 | (r, p, _) = residue(b, a) 98 | return (r, p, (b, a)) 99 | 100 | 101 | @njit 102 | def compute_m(x0: float, x1: float, y0: float, y1: float) -> float: 103 | return (y1 - y0) / (x1 - x0) 104 | 105 | 106 | @njit 107 | def compute_q(x0: float, x1: float, y0: float, y1: float) -> float: 108 | return (y0 * (x1 - x0) - x0 * (y1 - y0)) / (x1 - x0) 109 | 110 | 111 | @njit 112 | def compute_m_q_vectors(waveform: np.ndarray): 113 | """ 114 | Compute the m & q vectors needed by the paper algorithm. In the paper they have waveformq they know the shape of in 115 | advance. This function allows you to compute m & q for any kind of waveform 116 | 117 | About the comments in the function : 118 | Notes that when the slope is too big (threshold is empiric), this replicate the previous m & q to mimic the 119 | m & q definitions found in the paper (ie : in the paper they do have non-linearities in m & q). 120 | """ 121 | size = waveform.shape[0] 122 | # slope_thrsd = size / 64 123 | # idx_to_estimate = [] 124 | m = np.zeros(size) 125 | q = np.zeros(size) 126 | X = np.linspace(0, 1, waveform.shape[0] + 1) 127 | 128 | for i in range(size - 1): 129 | y0 = waveform[i] 130 | y1 = waveform[i + 1] 131 | x0 = X[i] 132 | x1 = X[i + 1] 133 | m_i = compute_m(x0, x1, y0, y1) 134 | q_i = compute_q(x0, x1, y0, y1) 135 | 136 | # if abs(m_i) > slope_thrsd: 137 | # # print("Exceeded threshold at idx {} of waveform {}".format(i, waveform.shape[0])) 138 | # idx_to_estimate.append(i) 139 | # continue 140 | 141 | m[i] = m_i 142 | q[i] = q_i 143 | 144 | m[-1] = np.single( 145 | compute_m( 146 | np.double(X[-2]), 147 | np.double(X[-1]), 148 | np.double(waveform[-1]), 149 | np.double(waveform[0]), 150 | ) 151 | ) 152 | q[-1] = np.single( 153 | compute_q( 154 | np.double(X[-2]), 155 | np.double(X[-1]), 156 | np.double(waveform[-1]), 157 | np.double(waveform[0]), 158 | ) 159 | ) 160 | # if abs(m[-1]) > slope_thrsd: 161 | # j = size-2 162 | # while j in idx_to_estimate: 163 | # j -= 1 164 | # m[-1] = m[j] 165 | # q[-1] = q[j] 166 | 167 | # for i in idx_to_estimate: 168 | # m[i] = m[i-1] 169 | # q[i] = q[i-1] 170 | 171 | return (m, q) 172 | 173 | 174 | @njit 175 | def process_naive_linear(waveform, x_values): 176 | """ 177 | Linear interpolation algorithm 178 | """ 179 | y = np.zeros(x_values.shape[0]) 180 | waveform_len = waveform.shape[0] 181 | 182 | for i, x in enumerate(x_values): 183 | if i == 0: 184 | continue 185 | 186 | x_red = x % 1.0 187 | relative_idx = x_red * waveform_len 188 | 189 | prev_idx = floor(relative_idx) 190 | next_idx = (prev_idx + 1) % waveform_len 191 | 192 | if relative_idx == prev_idx: 193 | y[i] = waveform[prev_idx] 194 | else: 195 | a = (waveform[next_idx] - waveform[prev_idx]) / (next_idx - prev_idx) 196 | b = waveform[prev_idx] - prev_idx * a 197 | y[i] = a * relative_idx + b 198 | 199 | return y 200 | 201 | 202 | # @njit 203 | def normalized_fft(time_signal, padding: int = 0) -> np.ndarray: 204 | signal_len = time_signal.shape[0] 205 | 206 | # Pad with zeros 207 | padding_len = signal_len + padding * 2 208 | if padding != 0: 209 | padded_signal = np.zeros(padding_len) 210 | padded_signal[padding:-padding] = time_signal 211 | time_signal = padded_signal 212 | 213 | # window = np.blackman(padding_len) 214 | window = np.kaiser(padding_len, 38) 215 | fft = np.fft.rfft(time_signal * window) 216 | return fft / np.max(np.abs(fft)) 217 | 218 | 219 | @njit 220 | def mq_from_waveform( 221 | waveform: np.ndarray, 222 | ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: 223 | (m, q) = compute_m_q_vectors(waveform) 224 | m_diff = np.zeros(m.shape[0]) 225 | q_diff = np.zeros(q.shape[0]) 226 | 227 | for i in range(m.shape[0] - 1): 228 | m_diff[i] = m[i + 1] - m[i] 229 | q_diff[i] = q[i + 1] - q[i] 230 | m_diff[-1] = m[0] - m[-1] 231 | q_diff[-1] = q[0] - q[-1] - m[0] 232 | 233 | return (m, q, m_diff, q_diff) 234 | 235 | 236 | def mipmap_mq_from_waveform( 237 | waveforms: List[np.ndarray[float]], 238 | ) -> Tuple[ 239 | List[np.ndarray[float]], 240 | List[np.ndarray[float]], 241 | List[np.ndarray[float]], 242 | List[np.ndarray[float]], 243 | ]: 244 | # ret = (list() for _ in range(4)) 245 | m_list = [] 246 | q_list = [] 247 | m_diff_list = [] 248 | q_diff_list = [] 249 | 250 | for waveform in waveforms: 251 | m, q, m_diff, q_diff = mq_from_waveform(waveform) 252 | m_list.append(m) 253 | q_list.append(q) 254 | m_diff_list.append(m_diff) 255 | q_diff_list.append(q_diff) 256 | 257 | return (m_list, q_list, m_diff_list, q_diff_list) 258 | 259 | 260 | from enum import Enum 261 | 262 | 263 | class Algorithm(Enum): 264 | ADAA_BUTTERWORTH = 1 265 | ADAA_CHEBYSHEV_TYPE2 = 2 266 | NAIVE = 3 267 | 268 | 269 | def process_adaa( 270 | x: np.ndarray, cache: AdaaCache, ftype: Algorithm, forder: int, os_factor: int 271 | ) -> Tuple[np.ndarray, str]: 272 | sr = SAMPLERATE * os_factor 273 | waveform_len = cache.m.shape[0] 274 | X = np.linspace(0, 1, waveform_len + 1, endpoint=True) 275 | x = np.mod(x, 1.0) 276 | 277 | assert ftype != Algorithm.NAIVE 278 | if ftype == Algorithm.ADAA_BUTTERWORTH: 279 | (r, p, _) = butter_coeffs(forder, BUTTERWORTH_CTF, sr) 280 | fname = "BT" 281 | else: 282 | (r, p, _) = cheby_coeffs(forder, CHEBY_CTF, 60, sr) 283 | fname = "CH" 284 | 285 | # filter_msg = "{} {}\nr : {}\np : {}".format(fname, forder, r, p) 286 | # logging.info(filter_msg) 287 | y = np.zeros(x.shape[0]) 288 | 289 | for order in range(0, forder, 2): 290 | ri = r[order] 291 | zi = p[order] 292 | y += process_bi_red(x, ri, zi, X, cache.m, cache.q, cache.m_diff, cache.q_diff) 293 | 294 | if os_factor == 2: 295 | ds_size = int(y.shape[0] / os_factor) 296 | y_ds = np.zeros((ds_size,)) 297 | decimator = Decimator17() 298 | for i in range(ds_size): 299 | y_ds[i] = decimator.process(y[i * 2], y[i * 2 + 1]) 300 | y = y_ds 301 | elif os_factor != 1: 302 | # Downsampling 303 | y = decimate(y, os_factor) 304 | 305 | name = "ADAA {} order {}".format(fname, forder) 306 | if os_factor != 1: 307 | name += "OVSx{}".format(os_factor) 308 | 309 | return (y, name) 310 | 311 | 312 | def process_adaa_mipmap( 313 | x: List[np.ndarray], 314 | cache: MipMapAdaaCache, 315 | ftype: Algorithm, 316 | forder: int, 317 | os_factor: int, 318 | ) -> Tuple[np.ndarray, str]: 319 | sr = SAMPLERATE * os_factor 320 | X = [np.linspace(0, 1, vec.shape[0] + 1, endpoint=True) for vec in cache.m_mipmap] 321 | x = np.mod(x, 1.0) 322 | 323 | assert ftype != Algorithm.NAIVE 324 | if ftype == Algorithm.ADAA_BUTTERWORTH: 325 | (r, p, _) = butter_coeffs(forder, BUTTERWORTH_CTF, sr) 326 | fname = "BT" 327 | else: 328 | (r, p, _) = cheby_coeffs(forder, CHEBY_CTF, 60, sr) 329 | fname = "CH" 330 | 331 | # filter_msg = "{} {}\nr : {}\np : {}".format(fname, forder, r, p) 332 | # logging.info(filter_msg) 333 | 334 | y = np.zeros(x.shape[0]) 335 | 336 | for order in range(0, forder, 2): 337 | ri = r[order] 338 | zi = p[order] 339 | y += process_bi_mipmap_xfading( 340 | x, 341 | ri, 342 | zi, 343 | X, 344 | cache.m_mipmap, 345 | cache.q_mipmap, 346 | cache.m_diff_mipmap, 347 | cache.q_diff_mipmap, 348 | cache.mipmap_scale, 349 | ) 350 | 351 | if os_factor == 2: 352 | ds_size = int(y.shape[0] / os_factor) 353 | y_ds = np.zeros((ds_size,)) 354 | decimator = Decimator9() 355 | for i in range(ds_size): 356 | y_ds[i] = decimator.process(y[i * 2], y[i * 2 + 1]) 357 | y = y_ds 358 | elif os_factor != 1: 359 | # Downsampling 360 | y = decimate(y, os_factor) 361 | 362 | name = "ADAA {} order {}".format(fname, forder) 363 | if os_factor != 1: 364 | name += "OVSx{}".format(os_factor) 365 | 366 | return (y, name) 367 | 368 | 369 | def process_naive( 370 | x: np.ndarray, waveform: np.ndarray, os_factor: int 371 | ) -> Tuple[np.ndarray, str]: 372 | # Waveform generation 373 | y = process_naive_linear(waveform, x) 374 | assert np.isnan(y).any() == False 375 | 376 | if os_factor != 1: 377 | y = decimate(y, os_factor) 378 | 379 | name = "naive" 380 | if os_factor != 1: 381 | name += "OVSx{}".format(os_factor) 382 | 383 | return (y, name) 384 | 385 | 386 | def generate_sweep_phase(f1, f2, t, fs, log_scale=False): 387 | # Calculate the number of samples 388 | n = int(t * fs) 389 | 390 | if log_scale: 391 | start = np.log2(f1) 392 | stop = np.log2(f2) 393 | freqs = np.logspace(start, stop, num=n - 1, base=2) 394 | else: 395 | freqs = np.linspace(f1, f2, n - 1) 396 | 397 | phases = np.zeros(n) 398 | 399 | phase = 0 400 | for i, freq in enumerate(freqs): 401 | step = freq / fs 402 | phase += step 403 | phases[i + 1] = phase 404 | 405 | return phases 406 | 407 | 408 | @dataclass 409 | class AlgorithmDetails: 410 | algorithm: Algorithm 411 | oversampling: int 412 | forder: int 413 | mipmap: bool = False 414 | waveform_len: int = 4096 // 2 415 | 416 | @property 417 | def name(self) -> str: 418 | name = "" 419 | if self.algorithm is Algorithm.NAIVE: 420 | name += "naive" 421 | else: 422 | name += "ADAA" 423 | if self.mipmap: 424 | name += "_mipmap" 425 | 426 | if self.algorithm is Algorithm.ADAA_BUTTERWORTH: 427 | name += "_BT" 428 | else: 429 | name += "_CH" 430 | name += "_order_{}".format(self.forder) 431 | 432 | if self.oversampling > 1: 433 | name += "_OVSx{}".format(self.oversampling) 434 | 435 | name += "_w{}".format(self.waveform_len) 436 | return name 437 | 438 | @property 439 | def num_harmonics(self) -> int: 440 | return self.waveform_len // 2 - 1 441 | 442 | def name_with_freq(self, freq: int) -> str: 443 | return self.name + "_{}Hz".format(freq) 444 | 445 | 446 | def routine( 447 | details: AlgorithmDetails, x, cache, freq: int = None 448 | ) -> Tuple[str, int, np.ndarray[float], AlgorithmDetails]: 449 | if freq is None: 450 | name = details.name 451 | else: 452 | name = details.name_with_freq(freq) 453 | logging.info("{} : started".format(name)) 454 | if details.algorithm is Algorithm.NAIVE: 455 | generated = process_naive(x, cache.waveform, details.oversampling)[0] 456 | elif not details.mipmap: 457 | generated = process_adaa( 458 | x, cache, details.algorithm, details.forder, os_factor=details.oversampling 459 | )[0] 460 | else: 461 | generated = process_adaa_mipmap( 462 | x, cache, details.algorithm, details.forder, os_factor=details.oversampling 463 | )[0] 464 | 465 | logging.info("{} : end".format(name)) 466 | return [name, freq, generated, details] 467 | 468 | 469 | def plot_psd(time_signals: Dict[str, np.ndarray[float]]): 470 | fig, axs = plt.subplots(len(time_signals) + 1) 471 | 472 | # Plot all psd 473 | for i, (name, signal) in enumerate(time_signals.items()): 474 | axs[i].psd(signal, label=name, Fs=SAMPLERATE, NFFT=4096) 475 | axs[-1].plot(signal, label=name) 476 | 477 | for ax in axs: 478 | ax.grid(True, which="both") 479 | ax.legend() 480 | 481 | plt.show() 482 | 483 | 484 | def plot_specgram(time_signals: Dict[str, np.ndarray[float]]): 485 | fig, axs = plt.subplots(len(time_signals)) 486 | 487 | for i, (name, data) in enumerate(time_signals.items()): 488 | axs[i].specgram(data, NFFT=512, noverlap=256, vmin=-80, Fs=44100) 489 | axs[i].set_title(name) 490 | axs[i].set_ylabel("Frequency [Hz]") 491 | axs[i].set_xlabel("Time [s]") 492 | axs[i].legend() 493 | 494 | plt.show() 495 | 496 | 497 | @njit 498 | def process_bi_mipmap_xfading( 499 | x, 500 | B, 501 | beta: complex, 502 | X_mipmap: List[np.ndarray[float]], 503 | m_mipmap: List[np.ndarray[float]], 504 | q_mipmap: List[np.ndarray[float]], 505 | m_diff_mipmap: List[np.ndarray[float]], 506 | q_diff_mipmap: List[np.ndarray[float]], 507 | mipmap_scale: np.ndarray[float], 508 | ): 509 | """ 510 | Bidirectionnal version of the algorithm, with mipmapping, phase and index reduction 511 | 512 | This is the version most compatible with real-time implementation 513 | """ 514 | y = np.zeros(x.shape[0]) 515 | 516 | # Period - should be 1 517 | for phases in X_mipmap: 518 | assert phases[-1] == 1.0 519 | 520 | expbeta = cexp(beta) 521 | 522 | # Initial condition 523 | prev_x = x[0] 524 | prev_cpx_y: complex = 0 525 | prev_x_diff = 0 526 | 527 | # Setting j indices and some reduced values 528 | x_red = prev_x % 1.0 529 | x_diff = x[1] - x[0] 530 | crossfader = CrossFader(ceil(CROSSFADE_S * SAMPLERATE), mipmap_scale, abs(x_diff)) 531 | prev_mipmap_idx = crossfader.prev_idx 532 | waveform_frames = m_mipmap[crossfader.prev_idx].shape[0] 533 | if x_diff > 0: 534 | j_red = floor(x_red * waveform_frames) 535 | else: 536 | j_red = ceil(x_red * waveform_frames) 537 | 538 | for n in range(1, x.shape[0]): 539 | # loop init 540 | x_diff = x[n] - prev_x 541 | if x_diff < -0.5: 542 | x_diff += 1.0 543 | elif x_diff > 0.5: 544 | x_diff -= 1.0 545 | 546 | mipmap_idx, weight, mipmap_idx_up, weight_up = crossfader.new_xfading_indices( 547 | abs(x_diff) 548 | ) 549 | waveform_frames = m_mipmap[mipmap_idx].shape[0] # aka k 550 | 551 | # Cautious, in this block, x_red still holds the value of the previous iteration 552 | if mipmap_idx != prev_mipmap_idx: 553 | # if prev_x_diff >= 0: 554 | # ref = floor(x_red * waveform_frames) 555 | # j_red = ref 556 | # else: 557 | # ref = ceil(x_red * waveform_frames) 558 | # j_red = ref 559 | 560 | if mipmap_idx > prev_mipmap_idx: 561 | # Going up in frequencies 562 | # print("Going up from ", j_red) 563 | j_red = j_red >> (mipmap_idx - prev_mipmap_idx) 564 | else: 565 | # Going down in frequencies 566 | j_red = j_red << (prev_mipmap_idx - mipmap_idx) 567 | if prev_x_diff >= 0: 568 | j_red += X_mipmap[mipmap_idx][j_red + 1] < x_red 569 | else: 570 | j_red -= X_mipmap[mipmap_idx][j_red - 1] > x_red 571 | # if prev_x_diff >= 0: 572 | # j_red = floor(x_red * waveform_frames) 573 | # else: 574 | # j_red = ceil(x_red * waveform_frames) 575 | # print("Going down from ", j_red) 576 | 577 | # j_red = j_red << (prev_mipmap_idx - mipmap_idx) 578 | 579 | prev_j_red = j_red 580 | if (x_diff >= 0 and prev_x_diff >= 0) or (x_diff < 0 and prev_x_diff <= 0): 581 | # If on the same slop as the previous iteration 582 | prev_j_red = j_red + int(np.sign(x_red - X_mipmap[mipmap_idx][j_red])) 583 | 584 | x_red = x[n] % 1.0 585 | 586 | # j_red = floor(x_red * waveform_frames) 587 | if x_diff >= 0: 588 | # playback going forward 589 | j_red = floor(x_red * waveform_frames) 590 | jmax = j_red 591 | jmin = prev_j_red 592 | else: 593 | # playback going backward 594 | j_red = ceil(x_red * waveform_frames) 595 | jmax = prev_j_red 596 | jmin = j_red 597 | 598 | jmin_red = (jmin - 1) % waveform_frames 599 | jmax_p_red = jmax % waveform_frames 600 | 601 | prev_x_red = prev_x % 1.0 602 | 603 | I_crt = compute_I_bi( 604 | m_mipmap[mipmap_idx], 605 | q_mipmap[mipmap_idx], 606 | m_diff_mipmap[mipmap_idx], 607 | q_diff_mipmap[mipmap_idx], 608 | X_mipmap[mipmap_idx], 609 | jmin, 610 | jmin_red, 611 | jmax_p_red, 612 | beta, 613 | expbeta, 614 | x_diff, 615 | prev_x_red, 616 | x_red, 617 | ) 618 | 619 | if weight_up != 0.0: 620 | jmin_red_up = jmin_red // 2 621 | jmax_p_red_up = jmax_p_red // 2 622 | 623 | I_up = compute_I_bi( 624 | m_mipmap[mipmap_idx_up], 625 | q_mipmap[mipmap_idx_up], 626 | m_diff_mipmap[mipmap_idx_up], 627 | q_diff_mipmap[mipmap_idx_up], 628 | X_mipmap[mipmap_idx_up], 629 | jmin, 630 | jmin_red_up, 631 | jmax_p_red_up, 632 | beta, 633 | expbeta, 634 | x_diff, 635 | prev_x_red, 636 | x_red, 637 | ) 638 | I_crt = I_crt * weight + weight_up * I_up 639 | 640 | beta_pow2 = beta**2 641 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * (I_crt / beta_pow2) 642 | y[n] = y_cpx.real 643 | 644 | prev_x = x[n] 645 | 646 | prev_cpx_y = y_cpx 647 | prev_x_diff = x_diff 648 | prev_mipmap_idx = mipmap_idx 649 | 650 | return y 651 | 652 | 653 | @njit 654 | def compute_I_bi( 655 | m, 656 | q, 657 | m_diff, 658 | q_diff, 659 | X, 660 | jmin, 661 | jmin_red, 662 | jmax_p_red, 663 | beta, 664 | expbeta, 665 | x_diff, 666 | prev_x_red, 667 | x_red, 668 | ) -> complex: 669 | frames = m.shape[0] 670 | prev_x_red_bar = prev_x_red + (prev_x_red == 0.0) * (x_diff > 0) 671 | x_red_bar = x_red + (x_red == 0.0) * (x_diff < 0) 672 | 673 | if x_diff > 0: 674 | idx_prev_bound = jmin_red 675 | idx_next_bound = jmax_p_red 676 | else: 677 | idx_prev_bound = jmax_p_red 678 | idx_next_bound = jmin_red 679 | 680 | I = ( 681 | expbeta 682 | * ( 683 | m[idx_prev_bound] * x_diff 684 | + beta * (m[idx_prev_bound] * prev_x_red_bar + q[idx_prev_bound]) 685 | ) 686 | - m[idx_next_bound] * x_diff 687 | - beta * (m[idx_next_bound] * x_red_bar + q[idx_next_bound]) 688 | ) 689 | 690 | I_sum = 0 691 | born_sup = jmax_p_red + frames * (jmin_red > jmax_p_red) 692 | if x_diff < 0 and jmin != 0 and jmin_red > jmax_p_red: 693 | cycle_offset = -1.0 694 | else: 695 | cycle_offset = 0.0 696 | for i in range(jmin_red, born_sup): 697 | i_red = i % frames 698 | x_red_bar = x_red + cycle_offset + (i_red > jmax_p_red) 699 | 700 | I_sum += cexp(beta * (x_red_bar - X[i_red + 1]) / x_diff) * ( 701 | beta * q_diff[i_red] + m_diff[i_red] * (x_diff + beta * X[i_red + 1]) 702 | ) 703 | 704 | return I + np.sign(x_diff) * I_sum 705 | 706 | 707 | @njit 708 | def process_bi_red(x, B, beta: complex, X, m, q, m_diff, q_diff): 709 | """ 710 | Bidirectionnal version of the algorithm, with phase and index reduction, 711 | without mipmapping 712 | """ 713 | y = np.zeros(x.shape[0]) 714 | 715 | # Period - should be 1 716 | assert X[-1] == 1.0 717 | 718 | waveform_frames = m.shape[0] # aka k 719 | 720 | expbeta = cexp(beta) 721 | 722 | # Initial condition 723 | prev_x = x[0] 724 | prev_cpx_y: complex = 0 725 | prev_x_diff = 0 726 | 727 | # Setting j indexs and some reduced values 728 | x_red = prev_x % 1.0 729 | # j_red = binary_search_down(X, x_red, 0, X.shape[0] - 1) 730 | j_red = j_red = floor(x_red * (X.shape[0] - 1)) 731 | __j = waveform_frames * floor(prev_x / 1.0) + j_red - 1 732 | __jred = j_red 733 | 734 | for n in range(1, x.shape[0]): 735 | # loop init 736 | x_diff = x[n] - prev_x 737 | if x_diff > 0.5: 738 | x_diff -= 1 739 | elif x_diff < -0.5: 740 | x_diff += 1 741 | # prev_x_red_bar = x_red + (x_red == 0.0) # To replace (prev_x - T * floor(j_min/ waveform_frames)) 742 | prev_j_red = j_red % waveform_frames 743 | __prevj = __j 744 | 745 | # TODO: No idea ? 746 | if (x_diff >= 0 and prev_x_diff >= 0) or (x_diff < 0 and prev_x_diff <= 0): 747 | # If on the same slop as the previous iteration 748 | prev_j_red = j_red + int(np.sign(x_red - X[j_red])) 749 | __prevj = __j + int(np.sign(x_red - X[__jred])) 750 | # Is used to avoid computing a new j_min using the binary search, because 751 | # if j_min == j_max then the I sum is zero so its corresponds to the case 752 | # where x_n and x_n+1 are in the same interval 753 | 754 | x_red = x[n] % 1.0 755 | 756 | # j_red = floor(x_red * waveform_frames) 757 | if x_diff >= 0: 758 | # playback going forward 759 | j_red = floor(x_red * waveform_frames) 760 | jmax = j_red 761 | jmin = prev_j_red 762 | 763 | # OG 764 | __jred = floor(x_red * waveform_frames) 765 | __j = waveform_frames * floor(x[n] / 1.0) + __jred - 1 766 | __jmin = __prevj 767 | __jmax = __j 768 | else: 769 | # playback going backward 770 | j_red = ceil(x_red * waveform_frames) 771 | jmax = prev_j_red 772 | jmin = j_red 773 | 774 | # OG 775 | __jred = ceil(x_red * waveform_frames) 776 | __j = waveform_frames * floor(x[n] / 1.0) + __jred - 1 777 | __jmin = __j 778 | __jmax = __prevj 779 | 780 | j_min_red = (jmin - 1) % waveform_frames 781 | j_max_p_red = jmax % waveform_frames 782 | __jminred = __jmin % waveform_frames 783 | __jmaxpred = (__jmax + 1) % waveform_frames 784 | assert j_min_red == __jminred 785 | assert j_max_p_red == __jmaxpred 786 | 787 | prev_x_red_bar = prev_x % 1.0 788 | prev_x_red_bar += (prev_x_red_bar == 0.0) * (x_diff >= 0) 789 | 790 | # Check the values of prev_x_red_bar 791 | if x_diff >= 0: 792 | ref = prev_x - floor(__jmin / waveform_frames) 793 | assert prev_x_red_bar == ref 794 | else: 795 | ref = prev_x - floor((__jmax + 1) / waveform_frames) 796 | assert prev_x_red_bar == ref 797 | 798 | # Check the values of x_red_bar2 799 | x_red_bar2 = x_red + (x_red == 0.0) * (x_diff < 0) 800 | if x_diff >= 0: 801 | ref = x[n] - floor((__jmax + 1) / waveform_frames) 802 | assert x_red_bar2 == ref 803 | else: 804 | ref = x[n] - floor(__jmin / waveform_frames) 805 | assert x_red_bar2 == ref 806 | 807 | if x_diff >= 0: 808 | I = ( 809 | expbeta 810 | * ( 811 | m[j_min_red] * x_diff 812 | + beta * (m[j_min_red] * prev_x_red_bar + q[j_min_red]) 813 | ) 814 | - m[j_max_p_red] * x_diff 815 | - beta * (m[j_max_p_red] * x_red_bar2 + q[j_max_p_red]) 816 | ) 817 | else: 818 | I = ( 819 | expbeta 820 | * ( 821 | m[j_max_p_red] * x_diff 822 | + beta * (m[j_max_p_red] * prev_x_red_bar + q[j_max_p_red]) 823 | ) 824 | - m[j_min_red] * x_diff 825 | - beta * (m[j_min_red] * x_red_bar2 + q[j_min_red]) 826 | ) 827 | 828 | I_sum = 0 829 | born_sup = j_max_p_red + waveform_frames * (j_min_red > j_max_p_red) 830 | if x_diff < 0 and jmin != 0 and j_min_red > j_max_p_red: 831 | cycle_offset = -1.0 832 | else: 833 | cycle_offset = 0.0 834 | 835 | # Checking the sum bounds 836 | # assert(born_sup - j_min_red == __jmax + 1 - __jmin) 837 | for i in range(j_min_red, born_sup): 838 | i_red = i % waveform_frames 839 | 840 | # Sol A : 841 | # if x_diff > 0: 842 | # x_red_bar = x_red + (i_red >= j_min_red) 843 | # else: 844 | # x_red_bar = x_red - (x_red > X[i_red]) * (i_red != j_min_red) 845 | # x_red_bar += (i == -1) 846 | 847 | # Sol B : 848 | x_red_bar = x_red + cycle_offset + (i_red > j_max_p_red) 849 | 850 | I_sum += cexp(beta * (x_red_bar - X[i_red + 1]) / x_diff) * ( 851 | beta * q_diff[i_red] + m_diff[i_red] * (x_diff + beta * X[i_red + 1]) 852 | ) 853 | 854 | I = (I + np.sign(x_diff) * I_sum) / (beta**2) 855 | 856 | # See formula (10) 857 | y_cpx: complex = expbeta * prev_cpx_y + 2 * B * I 858 | y[n] = y_cpx.real 859 | 860 | prev_x = x[n] 861 | prev_cpx_y = y_cpx 862 | prev_x_diff = x_diff 863 | 864 | return y 865 | 866 | 867 | if __name__ == "__main__": 868 | parser = argparse.ArgumentParser(description="Script with optional arguments") 869 | 870 | # Define the mode argument as a choice between "psd", "metrics", and "sweep" 871 | parser.add_argument( 872 | "mode", 873 | choices=["psd", "metrics", "sweep"], 874 | help="Choose a mode: psd, metrics, or sweep", 875 | ) 876 | 877 | # Define the export argument as a choice between "snr", "sinad", and "both" 878 | parser.add_argument( 879 | "--export", 880 | choices=["snr", "sinad", "both"], 881 | default="both", 882 | help="Choose what to export: snr, sinad, or both (default)", 883 | ) 884 | parser.add_argument("--export-dir", type=Path, default=Path.cwd()) 885 | parser.add_argument( 886 | "--export-audio", action="store_true", help="Export the generated audio" 887 | ) 888 | parser.add_argument( 889 | "--export-phase", action="store_true", help="Export the generated phase vectors" 890 | ) 891 | parser.add_argument("-v", "--verbose", action="store_true", help="Enabled logging") 892 | parser.add_argument( 893 | "-F", 894 | "--flip", 895 | action="store_true", 896 | help="Flip the generated phase vector to test backward playback", 897 | ) 898 | 899 | args = parser.parse_args() 900 | 901 | if args.mode != "metrics": 902 | args.export = "none" 903 | 904 | if args.verbose: 905 | logging.getLogger().setLevel(logging.INFO) 906 | 907 | import matlab.engine 908 | 909 | FREQS = np.logspace(start=5, stop=14.4, num=100, base=2) 910 | # FREQS = [416] 911 | # FREQS = [noteToFreq(i) for i in range(128)] 912 | 913 | ALGOS_OPTIONS = [ 914 | AlgorithmDetails(Algorithm.NAIVE, 1, 0), 915 | AlgorithmDetails(Algorithm.NAIVE, 2, 0), 916 | AlgorithmDetails(Algorithm.NAIVE, 4, 0), 917 | AlgorithmDetails(Algorithm.NAIVE, 8, 0), 918 | AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=True), 919 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False), 920 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=1024), 921 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=512), 922 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=256), 923 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=128), 924 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=64), 925 | # AlgorithmDetails(Algorithm.ADAA_BUTTERWORTH, 1, 2, mipmap=False, waveform_len=32), 926 | AlgorithmDetails(Algorithm.ADAA_CHEBYSHEV_TYPE2, 1, 8, mipmap=True), 927 | # AlgorithmDetails(Algorithm.ADAA_CHEBYSHEV_TYPE2, 1, 8, mipmap=True), 928 | ] 929 | sorted_bl = dict() 930 | 931 | # Prepare parallel run 932 | logging.info("Generating caches for computation") 933 | routine_args = [] 934 | mipmap_caches = dict() 935 | adaa_caches: Dict[int, AdaaCache] = dict() 936 | naive_caches: Dict[int, NaiveCache] = dict() 937 | 938 | # waveform = FileWaveform("wavetables/massacre.wav") 939 | waveform = NaiveWaveform(NaiveWaveform.Type.SAW, 2048, 44100) 940 | 941 | if args.export != "none" or args.export_audio or args.export_phase: 942 | args.export_dir.mkdir(parents=True, exist_ok=True) 943 | 944 | # Init caches 945 | if any(opt.mipmap and opt.algorithm != Algorithm.NAIVE for opt in ALGOS_OPTIONS): 946 | # Init mipmap cache 947 | scale = mipmap_scale(waveform.size, SAMPLERATE, 9) 948 | mipmap_waveforms = [ 949 | waveform.get(waveform.size / (2**i)) for i in range(len(scale) + 1) 950 | ] 951 | (m, q, m_diff, q_diff) = mipmap_mq_from_waveform(mipmap_waveforms) 952 | mipmap_cache = MipMapAdaaCache(m, q, m_diff, q_diff, scale) 953 | 954 | if any( 955 | not opt.mipmap and opt.algorithm != Algorithm.NAIVE for opt in ALGOS_OPTIONS 956 | ): 957 | # Init basic adaa cache 958 | for opt in ALGOS_OPTIONS: 959 | if opt.waveform_len not in adaa_caches: 960 | (m, q, m_diff, q_diff) = mq_from_waveform( 961 | waveform.get(opt.waveform_len) 962 | ) 963 | adaa_caches[opt.waveform_len] = AdaaCache(m, q, m_diff, q_diff) 964 | 965 | if any(opt.algorithm == Algorithm.NAIVE for opt in ALGOS_OPTIONS): 966 | # Init naive cache 967 | for opt in ALGOS_OPTIONS: 968 | if opt.waveform_len not in naive_caches: 969 | naive_caches[opt.waveform_len] = NaiveCache( 970 | waveform.get(opt.waveform_len) 971 | ) 972 | 973 | if args.mode == "sweep": 974 | for options in ALGOS_OPTIONS: 975 | x = generate_sweep_phase( 976 | 20, SAMPLERATE / 2, DURATION_S, SAMPLERATE * options.oversampling 977 | ) 978 | postfix = "" 979 | if args.flip: 980 | x = np.flip(x) 981 | postfix = "_flipped" 982 | 983 | if args.export_phase: 984 | filename = options.name + postfix + "_phase.wav" 985 | x_red = x % 1.0 986 | sf.write( 987 | args.export_dir / filename, 988 | x_red, 989 | samplerate=SAMPLERATE, 990 | subtype="FLOAT", 991 | ) 992 | 993 | if options.mipmap and options.algorithm != Algorithm.NAIVE: 994 | cache = mipmap_cache 995 | elif not options.mipmap and options.algorithm != Algorithm.NAIVE: 996 | cache = adaa_caches[options.waveform_len] 997 | else: 998 | cache = naive_caches[options.waveform_len] 999 | routine_args.append([options, x, cache]) 1000 | else: 1001 | if args.mode == "psd" and len(FREQS) > 1: 1002 | logging.error("PSD mode only support a single frequency value") 1003 | exit(1) 1004 | 1005 | bl_gen_args = [ 1006 | [ 1007 | np.linspace( 1008 | 0, DURATION_S, num=int(DURATION_S * SAMPLERATE), endpoint=False 1009 | ), 1010 | freq, 1011 | ] 1012 | for freq in FREQS 1013 | ] 1014 | logging.info("Computing band limited versions") 1015 | with Pool(NUM_PROCESS) as pool: 1016 | bl_results = pool.starmap(bl_sawtooth, bl_gen_args) 1017 | 1018 | for i, freq in enumerate(FREQS): 1019 | sorted_bl[freq] = bl_results[i] 1020 | for options in ALGOS_OPTIONS: 1021 | if options.mipmap and options.algorithm != Algorithm.NAIVE: 1022 | cache = mipmap_cache 1023 | elif not options.mipmap and options.algorithm != Algorithm.NAIVE: 1024 | cache = adaa_caches[options.waveform_len] 1025 | else: 1026 | cache = naive_caches[options.waveform_len] 1027 | 1028 | num_frames = int(DURATION_S * SAMPLERATE * options.oversampling) 1029 | x = np.linspace(0.0, DURATION_S * freq, num_frames, endpoint=True) 1030 | 1031 | postfix = "" 1032 | if args.flip: 1033 | x = np.flip(x) 1034 | postfix = "_flipped" 1035 | 1036 | if args.export_phase: 1037 | filename = options.name + postfix + "_phase.wav" 1038 | x_red = x % 1.0 1039 | sf.write( 1040 | args.export_dir / filename, 1041 | x_red, 1042 | samplerate=SAMPLERATE, 1043 | subtype="FLOAT", 1044 | ) 1045 | # x = np.flip(x) 1046 | # x = generate_sweep_phase(200, SAMPLERATE / 2, DURATION_S, SAMPLERATE * options.oversampling) 1047 | routine_args.append([options, x, cache, freq]) 1048 | 1049 | # Run generating in parallel 1050 | logging.info( 1051 | "Computing naive and ADAA iterations using {} process".format(NUM_PROCESS) 1052 | ) 1053 | with Pool(NUM_PROCESS) as pool: 1054 | results = pool.starmap(routine, routine_args) 1055 | logging.info("Computation ended, cleaning caches") 1056 | 1057 | # Delete caches to free memory 1058 | del mipmap_caches 1059 | del adaa_caches 1060 | del naive_caches 1061 | 1062 | if args.export_audio: 1063 | if args.flip: 1064 | postfix = "_flipped" 1065 | else: 1066 | postfix = "" 1067 | 1068 | for res in results: 1069 | filename = res[0] + postfix + ".wav" 1070 | sf.write( 1071 | args.export_dir / filename, res[2] * 0.50, SAMPLERATE, subtype="FLOAT" 1072 | ) 1073 | 1074 | if args.mode == "psd": 1075 | logging.info("Plotting psd") 1076 | signals = {name: signal for name, _, signal, _ in results} 1077 | plot_psd(signals) 1078 | 1079 | elif args.mode == "sweep": 1080 | logging.info("Plotting sweep spectrogram") 1081 | signals = {name: signal for name, _, signal, _ in results} 1082 | plot_specgram(signals) 1083 | 1084 | else: 1085 | # SNR export 1086 | if args.export in ("snr", "both"): 1087 | assert args.export_dir is not None 1088 | 1089 | logging.info("Computing SNRs") 1090 | sorted_snr = defaultdict(list) 1091 | snr_args = [ 1092 | [ 1093 | np.abs(normalized_fft(data)), 1094 | SAMPLERATE, 1095 | freq, 1096 | aam.Harmonics.ALL, 1097 | ] 1098 | for (_, freq, data, _) in results 1099 | ] 1100 | snr_values = [aam.snr(*args) for args in snr_args] 1101 | for i, (_, freq, _, _) in enumerate(results): 1102 | sorted_snr[freq].append(snr_values[i]) 1103 | 1104 | logging.info("Exporting SNR to CSV") 1105 | csv_output = args.export_dir / (args.export_dir.name + "_snr.csv") 1106 | with open(csv_output, "w") as csv_file: 1107 | csvwriter = csv.writer(csv_file) 1108 | names = [opt.name for opt in ALGOS_OPTIONS] 1109 | csvwriter.writerow(["frequency", *names]) 1110 | 1111 | for freq, snr_values in sorted_snr.items(): 1112 | csvwriter.writerow([freq, *snr_values]) 1113 | 1114 | del sorted_snr 1115 | 1116 | # SINAD 1117 | if args.export in ("sinad", "both"): 1118 | assert args.export_dir is not None 1119 | 1120 | logging.info("Computing SINADs") 1121 | sorted_sinad = defaultdict(list) 1122 | sinad_args = [ 1123 | [ 1124 | np.abs(normalized_fft(data)), 1125 | np.abs(normalized_fft(sorted_bl[freq])), 1126 | SAMPLERATE, 1127 | freq, 1128 | compute_harmonic_factors(freq, SAMPLERATE, details.num_harmonics), 1129 | ] 1130 | for (_, freq, data, details) in results 1131 | ] 1132 | sinad_values = [aam.sinad(*args) for args in sinad_args] 1133 | 1134 | for i, (_, freq, _, _) in enumerate(results): 1135 | sorted_sinad[freq].append(sinad_values[i]) 1136 | 1137 | logging.info("Exporting SINAD to CSV") 1138 | sinad_output = args.export_dir / (args.export_dir.name + "_sinad.csv") 1139 | with open(sinad_output, "w") as csv_file: 1140 | csvwriter = csv.writer(csv_file) 1141 | names = [opt.name for opt in ALGOS_OPTIONS] 1142 | csvwriter.writerow(["frequency", *names]) 1143 | 1144 | for freq, sinad_values in sorted_sinad.items(): 1145 | csvwriter.writerow([freq, *sinad_values]) 1146 | -------------------------------------------------------------------------------- /python/metrics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from math import floor 3 | from numba import njit 4 | 5 | 6 | @njit 7 | def compute_harmonic_factors(fund: float, sr: float, max: int = -1) -> np.ndarray[int]: 8 | nyquist = sr / 2.0 9 | return np.arange(2, floor(nyquist / fund) + 1)[:max] 10 | -------------------------------------------------------------------------------- /python/mipmap.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from numba import njit, int32, float64 3 | from numba.experimental import jitclass 4 | import numpy as np 5 | 6 | # import soxr 7 | import logging 8 | 9 | from typing import List, Tuple 10 | 11 | 12 | @dataclass 13 | class MipMapDetails: 14 | min_pow: int 15 | max_pow: int 16 | 17 | def generate_scale(self, samplerate: int): 18 | return np.array( 19 | [2**idx / samplerate for idx in range(self.min_pow, self.max_pow + 1)], 20 | dtype=np.float32, 21 | ) 22 | 23 | 24 | crossfader_spec = [ 25 | ("__num_samples", int32), 26 | ("__samples_left", int32), 27 | ("__scale", float64[:]), 28 | ("__prev_idx", int32), 29 | ("__next_idx", int32), 30 | ] 31 | 32 | 33 | @jitclass(crossfader_spec) 34 | class CrossFader: 35 | def __init__( 36 | self, 37 | num_samples: int, 38 | mipmap_scale: np.ndarray[np.float64], 39 | init_phase_diff: int, 40 | ): 41 | self.__num_samples = num_samples 42 | self.__samples_left = 0 43 | self.__scale = mipmap_scale 44 | self.__prev_idx = np.searchsorted(self.__scale, init_phase_diff) 45 | self.__next_idx = self.__prev_idx 46 | 47 | @property 48 | def prev_idx(self) -> int: 49 | return self.__prev_idx 50 | 51 | def __compute_fading(self) -> float: 52 | return self.__samples_left / self.__num_samples 53 | 54 | def __update_indices(self, new_idx: int): 55 | if new_idx > self.__prev_idx: 56 | # Higher new table 57 | self.__prev_idx = new_idx - 1 58 | else: 59 | # Lower new table 60 | self.__prev_idx = new_idx + 1 61 | self.__next_idx = new_idx 62 | 63 | def new_xfading_indices(self, phase_diff: float) -> Tuple[int, float, int, float]: 64 | new_idx = np.searchsorted(self.__scale, phase_diff) 65 | if self.__samples_left != 0: 66 | # Already cross-fading 67 | assert self.__prev_idx != self.__next_idx 68 | 69 | if new_idx != self.__next_idx: 70 | self.__update_indices(new_idx) 71 | 72 | self.__samples_left -= 1 73 | # Reached the end 74 | if self.__samples_left == 0: 75 | self.__prev_idx = self.__next_idx 76 | else: 77 | # Not crossfading yet 78 | if new_idx == self.__next_idx: 79 | assert self.__prev_idx == self.__next_idx 80 | return (new_idx, 1.0, new_idx + 1, 0.0) 81 | 82 | # Reset the xfading counter 83 | self.__samples_left = self.__num_samples 84 | 85 | self.__update_indices(new_idx) 86 | 87 | fading = self.__compute_fading() 88 | if self.__next_idx > self.__prev_idx: 89 | return (self.__prev_idx, fading, self.__next_idx, 1.0 - fading) 90 | else: 91 | return (self.__next_idx, 1.0 - fading, self.__prev_idx, fading) 92 | 93 | 94 | @njit 95 | def mipmap_size(min_pow: int, max_pow: int): 96 | return max_pow - min_pow + 2 97 | 98 | 99 | def mipmap_scale(max_size: int, samplerate: float, num: int) -> np.ndarray[float]: 100 | start = samplerate / max_size * 3 # Empiric 101 | freqs = np.array([start * 2**i for i in range(num)]) 102 | logging.info("Frequency mipmap scale : {}".format(freqs)) 103 | logging.info("Phase mipmap scale : {}".format(freqs / samplerate)) 104 | return freqs / samplerate 105 | 106 | 107 | ################################################################################ 108 | ## LEGACY CODE ## 109 | ################################################################################ 110 | 111 | # @njit 112 | # def find_mipmap_index(phase_diff: float, scale: np.ndarray[float]) -> int: 113 | # return np.searchsorted(scale, phase_diff) 114 | 115 | 116 | # @njit 117 | # def find_mipmap_xfading_indexes( 118 | # phase_diff: float, scale: np.ndarray[float] 119 | # ) -> Tuple[int, float, int, float]: 120 | # THRESHOLD_FACTOR = 0.98 121 | # mipmap_idx = np.searchsorted(scale, phase_diff) 122 | 123 | # # Reached last index, can't cross fade 124 | # if mipmap_idx == scale.shape[0]: 125 | # return (mipmap_idx, 1.0, None, 0.0) 126 | 127 | # threshold = scale[mipmap_idx] * (1.0 + THRESHOLD_FACTOR) / 2 128 | 129 | # if phase_diff < threshold: 130 | # # Below threshold, we don't crossfade 131 | # return (mipmap_idx, 1.0, mipmap_idx + 1, 0.0) 132 | # else: 133 | # # Above threshold, starting crossfade 134 | # a = 1.0 / (scale[mipmap_idx] - threshold) 135 | # b = -threshold * a 136 | # factor_next = a * phase_diff + b 137 | # factor_crt = 1.0 - factor_next 138 | # return (mipmap_idx, factor_crt, mipmap_idx + 1, factor_next) 139 | 140 | 141 | # def compute_mipmap_waveform( 142 | # waveform: np.ndarray[float], count: int 143 | # ) -> List[np.ndarray[float]]: 144 | # return [soxr.resample(waveform, 2**i, 1, quality="VHQ") for i in range(count)] 145 | 146 | 147 | if __name__ == "__main__": 148 | scale = mipmap_scale(2048, 44100, 5) 149 | 150 | # a = find_mipmap_index_xfading(scale[0], scale) 151 | # for i in range(5): 152 | # print("{} {}".format(i, find_mipmap_index_xfading(scale[i], scale))) 153 | # print("\n\n===") 154 | # for factor in (1.0, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99, 0.999): 155 | # b = find_mipmap_xfading_indexes(scale[0] * factor, scale) 156 | # print(factor, b) 157 | -------------------------------------------------------------------------------- /python/utils.py: -------------------------------------------------------------------------------- 1 | def table_size(n, init_size): 2 | sm = 0 3 | for i in range(n): 4 | sm += init_size / (2**i) 5 | return sm 6 | 7 | 8 | # print(table_size(8, 2048)) 9 | -------------------------------------------------------------------------------- /python/waveform.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import soundfile as sf 3 | from pathlib import Path 4 | from enum import Enum 5 | from numba import njit 6 | import samplerate 7 | 8 | 9 | class Waveform: 10 | def __init__(self, data: np.ndarray, samplerate: int): 11 | self.__data = data 12 | self.__sr = samplerate 13 | 14 | @property 15 | def size(self) -> int: 16 | return self.__data.shape[0] 17 | 18 | def get(self, size: int = None) -> np.ndarray[float]: 19 | if size == None or size == self.size: 20 | return self.__data[:] 21 | else: 22 | ratio = self.size / size 23 | return self.__resample(ratio) 24 | 25 | def __resample(self, ratio: float) -> np.ndarray[float]: 26 | repetitions = 5 27 | if ratio < 256: 28 | extended = np.zeros(self.size * repetitions) 29 | o_size = int(np.floor(self.size / ratio)) 30 | for i in range(repetitions): 31 | extended[self.size * i : self.size * (i + 1)] = self.__data 32 | 33 | resampled = samplerate.resample(extended, 1.0 / ratio) 34 | return resampled[ 35 | o_size * (repetitions // 2) : o_size * (repetitions // 2 + 1) 36 | ] 37 | else: 38 | intermediate_ratio = ratio / 256 39 | intermediate_waveform = self.__resample(intermediate_ratio) 40 | intermediate_size = intermediate_waveform.shape[0] 41 | extended = np.zeros(intermediate_size * repetitions) 42 | o_size = int(np.floor(self.size / ratio)) 43 | for i in range(repetitions): 44 | extended[ 45 | intermediate_size * i : intermediate_size * (i + 1) 46 | ] = intermediate_waveform 47 | 48 | resampled = samplerate.resample(extended, 1.0 / 256) 49 | return resampled[ 50 | o_size * (repetitions // 2) : o_size * (repetitions // 2 + 1) 51 | ] 52 | 53 | 54 | class FileWaveform(Waveform): 55 | def __init__(self, path: Path): 56 | data, sr = sf.read(path, dtype=np.float32) 57 | super().__init__(data, sr) 58 | 59 | 60 | class NaiveWaveform(Waveform): 61 | class Type(Enum): 62 | SAW = 0 63 | SQUARE = 1 64 | TRIANGLE = 2 65 | SIN = 3 66 | 67 | def __init__(self, type: Type, size: int, samplerate: int): 68 | if type == NaiveWaveform.Type.SAW: 69 | data = NaiveWaveform.__compute_saw(size) 70 | elif type == NaiveWaveform.Type.SIN: 71 | data = NaiveWaveform.__compute_sin(size) 72 | elif type == NaiveWaveform.Type.SQUARE: 73 | data = NaiveWaveform.__compute_square(size) 74 | else: 75 | raise NotImplementedError 76 | 77 | super().__init__(data, samplerate) 78 | 79 | @staticmethod 80 | @njit 81 | def __compute_saw(size: int) -> np.ndarray: 82 | phase = 0.0 83 | waveform = np.zeros(size) 84 | step = 1.0 / size 85 | 86 | for i in range(size): 87 | waveform[i] = 2.0 * phase - 1 88 | phase = (phase + step) % 1.0 89 | 90 | dc_offset = np.mean(waveform) 91 | waveform -= dc_offset 92 | 93 | return waveform 94 | 95 | @staticmethod 96 | @njit 97 | def __compute_sin(size: int) -> np.ndarray: 98 | phase = np.linspace(0, 2 * np.pi, size + 1) 99 | return np.sin(phase[:-1]) 100 | 101 | @staticmethod 102 | @njit 103 | def __compute_square(size: int) -> np.ndarray: 104 | data = np.ones(size) 105 | data[size / 2 :] *= -1.0 106 | return data 107 | 108 | 109 | if __name__ == "__main__": 110 | # wave = NaiveWaveform(NaiveWaveform.Type.SAW, 2048, 44100) 111 | wave = FileWaveform("wavetables/massacre.wav") 112 | 113 | sizes = (2048, 1024, 512, 256, 128, 64) 114 | 115 | waves = [wave.get(size) for size in sizes] 116 | 117 | for w in waves: 118 | print(w.shape) 119 | 120 | # wave_og = wave.get() 121 | # wave_1024 = wave.get(1024) 122 | # wave_512 = wave.get(512) 123 | # wave_256 = wave.get(256) 124 | # wave_128 = wave.get(128) 125 | # wave_64 = wave.get(64) 126 | 127 | # import soundfile as sf 128 | # sf.write("naive_saw.wav", wave_og, 44100) 129 | 130 | import matplotlib.pyplot as plt 131 | 132 | fig, axs = plt.subplots(nrows=3, ncols=2) 133 | 134 | for i, ax in enumerate(axs.flat): 135 | waveform = waves[i] 136 | print("{} : {}".format(waveform.shape[0], np.mean(waveform))) 137 | ax.plot(waveform, label="{}".format(waveform.shape[0])) 138 | 139 | # size = waveform.shape[0] 140 | # triple = np.zeros(size * 100) 141 | # for i in range(100): 142 | # triple[size*i: size*(i+1)] = waveform 143 | 144 | # triple *= np.blackman(triple.shape[0]) 145 | 146 | # ax.psd(triple, label="{}".format(waveform.shape[0])) 147 | 148 | ax.legend() 149 | # axs[0].plot(wave_og, label="2048") 150 | # axs[1].plot(wave_1024, label="1024") 151 | # axs[2].plot(wave_512, label="512") 152 | # axs[3].plot(wave_256, label="256") 153 | # axs[4].plot(wave_128, label="128") 154 | # axs[5].plot(wave_64, label="64") 155 | 156 | plt.show() 157 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==3.6.2 2 | numba==0.57.1 3 | numpy==1.24.1 4 | scipy==1.10.0 5 | soundfile==0.11.0 6 | samplerate==0.1.0 7 | pyaudioaliasingmetrics==0.1.0 --------------------------------------------------------------------------------