File size: 11,646 Bytes
4a9b62b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# -*- coding: utf-8 -*-

# Portions of this code are based on ContentVec.
# The original source code can be found at:
# https://github.com/auspicious3000/contentvec/blob/d746688a32940f4bee410ed7c87ec9cf8ff04f74/contentvec/data/audio/audio_utils_1.py
#
# Copyright (c) 2025 Yiwei Guo
# Licensed under the MIT license.

import os
import numpy as np
from scipy.signal import sosfilt
from scipy import signal as sg
import sox


def make_lowshelf(g, fc, Q, fs=44100):
    """Generate filter coefficients for 2nd order Lowshelf filter.
    This function follows the code from the JUCE DSP library 
    which can be found in `juce_IIRFilter.cpp`. 
    
    The design equations are based upon those found in the Cookbook 
    formulae for audio equalizer biquad filter coefficients
    by Robert Bristow-Johnson. 
    https://www.w3.org/2011/audio/audio-eq-cookbook.html
    Args:
        g  (float): Gain factor in dB.
        fc (float): Cutoff frequency in Hz.
        Q  (float): Q factor.
        fs (float): Sampling frequency in Hz.
    Returns:
        tuple: (b, a) filter coefficients 
    """
    # convert gain from dB to linear
    g = np.power(10,(g/20))

    # initial values
    A = np.max([0.0, np.sqrt(g)])
    aminus1 = A - 1
    aplus1 = A + 1
    omega = (2 * np.pi * np.max([fc, 2.0])) / fs
    coso = np.cos(omega)
    beta = np.sin(omega) * np.sqrt(A) / Q 
    aminus1TimesCoso = aminus1 * coso

    # coefs calculation
    b0 = A * (aplus1 - aminus1TimesCoso + beta)
    b1 = A * 2 * (aminus1 - aplus1 * coso)
    b2 = A * (aplus1 - aminus1TimesCoso - beta)
    a0 = aplus1 + aminus1TimesCoso + beta
    a1 = -2 * (aminus1 + aplus1 * coso)
    a2 = aplus1 + aminus1TimesCoso - beta

    # output coefs 
    #b = np.array([b0/a0, b1/a0, b2/a0])
    #a = np.array([a0/a0, a1/a0, a2/a0])

    return np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]])



def make_highself(g, fc, Q, fs=44100):
    """Generate filter coefficients for 2nd order Highshelf filter.
    This function follows the code from the JUCE DSP library 
    which can be found in `juce_IIRFilter.cpp`. 
    
    The design equations are based upon those found in the Cookbook 
    formulae for audio equalizer biquad filter coefficients
    by Robert Bristow-Johnson. 
    https://www.w3.org/2011/audio/audio-eq-cookbook.html
    Args:
        g  (float): Gain factor in dB.
        fc (float): Cutoff frequency in Hz.
        Q  (float): Q factor.
        fs (float): Sampling frequency in Hz.
    Returns:
        tuple: (b, a) filter coefficients 
    """
    # convert gain from dB to linear
    g = np.power(10,(g/20))

    # initial values
    A = np.max([0.0, np.sqrt(g)])
    aminus1 = A - 1
    aplus1 = A + 1
    omega = (2 * np.pi * np.max([fc, 2.0])) / fs
    coso = np.cos(omega)
    beta = np.sin(omega) * np.sqrt(A) / Q 
    aminus1TimesCoso = aminus1 * coso

    # coefs calculation
    b0 = A * (aplus1 + aminus1TimesCoso + beta)
    b1 = A * -2 * (aminus1 + aplus1 * coso)
    b2 = A * (aplus1 + aminus1TimesCoso - beta)
    a0 = aplus1 - aminus1TimesCoso + beta
    a1 = 2 * (aminus1 - aplus1 * coso)
    a2 = aplus1 - aminus1TimesCoso - beta

    # output coefs
    #b = np.array([b0/a0, b1/a0, b2/a0])
    #a = np.array([a0/a0, a1/a0, a2/a0])
      
    return np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]])



def make_peaking(g, fc, Q, fs=44100):
    """Generate filter coefficients for 2nd order Peaking EQ.
    This function follows the code from the JUCE DSP library 
    which can be found in `juce_IIRFilter.cpp`. 
    
    The design equations are based upon those found in the Cookbook 
    formulae for audio equalizer biquad filter coefficients
    by Robert Bristow-Johnson. 
    https://www.w3.org/2011/audio/audio-eq-cookbook.html
    Args:
        g  (float): Gain factor in dB.
        fc (float): Cutoff frequency in Hz.
        Q  (float): Q factor.
        fs (float): Sampling frequency in Hz.
    Returns:
        tuple: (b, a) filter coefficients 
    """
    # convert gain from dB to linear
    g = np.power(10,(g/20))

    # initial values
    A = np.max([0.0, np.sqrt(g)])
    omega = (2 * np.pi * np.max([fc, 2.0])) / fs
    alpha = np.sin(omega) / (Q * 2)
    c2 = -2 * np.cos(omega)
    alphaTimesA = alpha * A
    alphaOverA = alpha / A

    # coefs calculation
    b0 = 1 + alphaTimesA
    b1 = c2
    b2 = 1 - alphaTimesA
    a0 = 1 + alphaOverA
    a1 = c2
    a2 = 1 - alphaOverA

    # output coefs
    #b = np.array([b0/a0, b1/a0, b2/a0])
    #a = np.array([a0/a0, a1/a0, a2/a0])
    
    return np.array([[b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0]])



def params2sos(G, Fc, Q, fs):
    """Convert 5 band EQ paramaters to 2nd order sections.
    Takes a vector with shape (13,) of denormalized EQ parameters
    and calculates filter coefficients for each of the 5 filters.
    These coefficients (2nd order sections) are then stored into a
    single (5,6) matrix. This matrix can be fed to `scipy.signal.sosfreqz()`
    in order to determine the frequency response of the cascasd of
    all five biquad filters.
    Args:
        x  (float): Gain factor in dB.       
        fs (float): Sampling frequency in Hz.
    Returns:
        ndarray: filter coefficients for 5 band EQ stored in (5,6) matrix.
        [[b1_0, b1_1, b1_2, a1_0, a1_1, a1_2],  # lowshelf coefficients
         [b2_0, b2_1, b2_2, a2_0, a2_1, a2_2],  # first band coefficients
         [b3_0, b3_1, b3_2, a3_0, a3_1, a3_2],  # second band coefficients
         [b4_0, b4_1, b4_2, a4_0, a4_1, a4_2],  # third band coefficients
         [b5_0, b5_1, b5_2, a5_0, a5_1, a5_2]]  # highshelf coefficients
    """
    # generate filter coefficients from eq params
    c0 = make_lowshelf(G[0], Fc[0], Q[0], fs=fs)
    c1 = make_peaking (G[1], Fc[1], Q[1], fs=fs)
    c2 = make_peaking (G[2], Fc[2], Q[2], fs=fs)
    c3 = make_peaking (G[3], Fc[3], Q[3], fs=fs)
    c4 = make_peaking (G[4], Fc[4], Q[4], fs=fs)
    c5 = make_peaking (G[5], Fc[5], Q[5], fs=fs)
    c6 = make_peaking (G[6], Fc[6], Q[6], fs=fs)
    c7 = make_peaking (G[7], Fc[7], Q[7], fs=fs)
    c8 = make_peaking (G[8], Fc[8], Q[8], fs=fs)
    c9 = make_highself(G[9], Fc[9], Q[9], fs=fs)

    # stuff coefficients into second order sections structure
    sos = np.concatenate([c0,c1,c2,c3,c4,c5,c6,c7,c8,c9], axis=0)

    return sos


import parselmouth
def change_gender(x, fs, lo, hi, ratio_fs, ratio_ps, ratio_pr):
    s = parselmouth.Sound(x, sampling_frequency=fs)
    f0 = s.to_pitch_ac(pitch_floor=lo, pitch_ceiling=hi, time_step=0.8/lo)
    f0_np = f0.selected_array['frequency']
    f0_med = np.median(f0_np[f0_np!=0]).item()
    ss = parselmouth.praat.call([s, f0], "Change gender", ratio_fs, f0_med*ratio_ps, ratio_pr, 1.0)
    return ss.values.squeeze(0)

def change_gender_f0(x, fs, lo, hi, ratio_fs, new_f0_med, ratio_pr):
    s = parselmouth.Sound(x, sampling_frequency=fs)
    ss = parselmouth.praat.call(s, "Change gender", lo, hi, ratio_fs, new_f0_med, ratio_pr, 1.0)
    return ss.values.squeeze(0)

def parse_range(range_str):
    # (a,b)|(c,d)|p
    
    terms = range_str.split("|")
    assert len(terms)==3, range_str
    prob = float(terms[-1])
    ranges = [eval(terms[0]), eval(terms[1])]
    return ranges, prob

class FormantF0Perturb:
    def __init__(self, male_formant_range, female_formant_range, female_f0_range):
        # male_formant_range: (0.9,0.95)|(1.05,1.3)|0.25
        # first range, second range, prob of first range
        
        self.Qmin = 2
        self.Qmax = 5
        self.male_formant_range, self.male_formant_prob = parse_range(male_formant_range)
        self.female_formant_range, self.female_formant_prob = parse_range(female_formant_range)
        self.female_f0_range, self.female_f0_prob = parse_range(female_f0_range)
        
    def get_perturb_param(self, gender):
        # formant shift and pitch shift.
        if gender == "M":
            if np.random.random()<=self.male_formant_prob:
                ratio_fs = np.random.uniform(self.male_formant_range[0][0], self.male_formant_range[0][1])
            else:
                ratio_fs = np.random.uniform(self.male_formant_range[1][0], self.male_formant_range[1][1])
            return 75, 250, ratio_fs, 1.0
        else:
            if np.random.random()<=self.female_formant_prob:
                ratio_fs = np.random.uniform(self.female_formant_range[0][0], self.female_formant_range[0][1])
            else:
                ratio_fs = np.random.uniform(self.female_formant_range[1][0], self.female_formant_range[1][1])
            if np.random.random()<=self.female_f0_prob:
                ratio_ps = np.random.uniform(self.female_f0_range[0][0], self.female_f0_range[0][1])
            else:
                ratio_ps = np.random.uniform(self.female_f0_range[1][0], self.female_f0_range[1][1])
            return 100, 400, ratio_fs, ratio_ps

    def random_eq(self, wav, sr):
        Fc = np.exp(np.linspace(np.log(60), np.log(7600), 10))
        # rng = np.random.default_rng()
        z = np.random.uniform(0, 1, size=(10,))
        Q = self.Qmin * (self.Qmax / self.Qmin)**z
        G = np.random.uniform(-12, 12, size=(10,))
        sos = params2sos(G, Fc, Q, sr)
        wav = sosfilt(sos, wav)
        return wav

    def __call__(self, wav, sr, gender):
        # gender: "F" or "M"
        
        lo, hi, ratio_fs, ratio_ps = self.get_perturb_param(gender)
        # fs: formant shift. This controls timbre (higher -> more female). ps: pitch shift. This controls absolute pitch level
        # We fix the pitch range parameter to 1. So that we don't change the pitch variations
        ratio_pr = 1
        
        ss = change_gender(wav, sr, lo, hi, ratio_fs, ratio_ps, ratio_pr)
        
        return ss

class SpeedPerturb:
    def __init__(self, male_range, female_range):
        self.male_range, self.male_prob = parse_range(male_range)
        self.female_range, self.female_prob = parse_range(female_range)
        
    def __call__(self, wav, sr, gender):
        
        if gender == "F":
            ranges, prob = self.male_range, self.male_prob
        else:
            ranges, prob = self.female_range, self.female_prob
        
        if np.random.random()<=prob:
            speed_alpha = np.random.uniform(ranges[0][0], ranges[0][1])
        else:
            speed_alpha = np.random.uniform(ranges[1][0], ranges[1][1])
        
        self.transformer = sox.Transformer()
        self.transformer.set_globals(verbosity=1)  # disable warnings.
        self.transformer.speed(factor=speed_alpha)
        self.transformer.tempo(factor=1/speed_alpha)  # recover the original length. Only affects timbre and pitch.
        wav_perturb = self.transformer.build_array(input_array=wav, sample_rate_in=sr)
        return wav_perturb

class IdentityPerturb:
    def __init__(self, ):
        pass
    def __call__(self,wav, sr, gender):
        return wav
        
class Perturbs:
    def __init__(self, formant_f0_perturb_params, speed_perturb_params, speed_perturb_prob):
        self.speed_perturb_prob = speed_perturb_prob
        if speed_perturb_prob!=0.0:
            self.speed_perturber = SpeedPerturb(**speed_perturb_params)
        else:
            self.speed_perturber = IdentityPerturb()
        if speed_perturb_prob!=1.0:
            self.formant_perturber = FormantF0Perturb(**formant_f0_perturb_params)
        else:
            self.formant_perturber = IdentityPerturb()
    def __call__(self, wav, sr, gender):
        if np.random.random()<=self.speed_perturb_prob:
            return self.speed_perturber(wav,sr,gender)
        else:
            return self.formant_perturber(wav,sr,gender)