parent
6e9aa1b10c
commit
7ec40c3429
@ -0,0 +1,143 @@ |
|||||||
|
/* SerialManager_FreqShift_OA.h
|
||||||
|
* Demonstrate frequency shifting via frequency domain processing. |
||||||
|
* |
||||||
|
* Created: Chip Audette (OpenAudio) Aug 2019 |
||||||
|
* Built for the Tympan library for Teensy 3.6-based hardware |
||||||
|
* |
||||||
|
* Convert to Open Audio Bob Larkin June 2020 |
||||||
|
* |
||||||
|
* MIT License. Use at your own risk. |
||||||
|
*/ |
||||||
|
|
||||||
|
#include "AudioEffectFreqShiftFD_OA_F32.h" |
||||||
|
|
||||||
|
void AudioEffectFreqShiftFD_OA_F32::update(void) |
||||||
|
{ |
||||||
|
//get a pointer to the latest data
|
||||||
|
audio_block_f32_t *in_audio_block = AudioStream_F32::receiveReadOnly_f32(); |
||||||
|
if (!in_audio_block) return; |
||||||
|
|
||||||
|
//simply return the audio if this class hasn't been enabled
|
||||||
|
if (!enabled) { |
||||||
|
AudioStream_F32::transmit(in_audio_block); |
||||||
|
AudioStream_F32::release(in_audio_block); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
//convert to frequency domain
|
||||||
|
//FFT is in complex_2N_buffer, interleaved real, imaginary, real, imaginary, etc
|
||||||
|
myFFT.execute(in_audio_block, complex_2N_buffer); |
||||||
|
unsigned long incoming_id = in_audio_block->id; |
||||||
|
// We just passed ownership of in_audio_block to myFFT, so we can
|
||||||
|
// release it here as we won't use it here again.
|
||||||
|
AudioStream_F32::release(in_audio_block); |
||||||
|
|
||||||
|
// ////////////// Do your processing here!!!
|
||||||
|
|
||||||
|
//define some variables
|
||||||
|
int fftSize = myFFT.getNFFT(); |
||||||
|
int N_2 = fftSize / 2 + 1; |
||||||
|
int source_ind; // neg_dest_ind;
|
||||||
|
|
||||||
|
//zero out DC and Nyquist
|
||||||
|
//complex_2N_buffer[0] = 0.0; complex_2N_buffer[1] = 0.0;
|
||||||
|
//complex_2N_buffer[N_2] = 0.0; complex_2N_buffer[N_2] = 0.0;
|
||||||
|
|
||||||
|
//do the shifting
|
||||||
|
if (shift_bins < 0) { |
||||||
|
for (int dest_ind=0; dest_ind < N_2; dest_ind++) { |
||||||
|
source_ind = dest_ind - shift_bins; |
||||||
|
if (source_ind < N_2) { |
||||||
|
complex_2N_buffer[2 * dest_ind] = complex_2N_buffer[2 * source_ind]; //real
|
||||||
|
complex_2N_buffer[(2 * dest_ind) + 1] = complex_2N_buffer[(2 * source_ind) + 1]; //imaginary
|
||||||
|
} else { |
||||||
|
complex_2N_buffer[2 * dest_ind] = 0.0; |
||||||
|
complex_2N_buffer[(2 * dest_ind) + 1] = 0.0; |
||||||
|
} |
||||||
|
} |
||||||
|
} else if (shift_bins > 0) { |
||||||
|
//do reverse order because, otherwise, we'd overwrite our source indices with zeros!
|
||||||
|
for (int dest_ind=N_2-1; dest_ind >= 0; dest_ind--) { |
||||||
|
source_ind = dest_ind - shift_bins; |
||||||
|
if (source_ind >= 0) { |
||||||
|
complex_2N_buffer[2 * dest_ind] = complex_2N_buffer[2 * source_ind]; //real
|
||||||
|
complex_2N_buffer[(2 * dest_ind) + 1] = complex_2N_buffer[(2 * source_ind) +1]; //imaginary
|
||||||
|
} else { |
||||||
|
complex_2N_buffer[2 * dest_ind] = 0.0; |
||||||
|
complex_2N_buffer[(2 * dest_ind) + 1] = 0.0; |
||||||
|
} |
||||||
|
}
|
||||||
|
} |
||||||
|
|
||||||
|
//here's the tricky bit! If the phase shift is an odd number of bins, we must manually evolve the phase through time
|
||||||
|
if ((abs(shift_bins) % 2) == 1) { |
||||||
|
switch (overlap_amount) { |
||||||
|
case NONE: |
||||||
|
//no phase change needed
|
||||||
|
break; |
||||||
|
case HALF: |
||||||
|
//alternate adding 180 deg...which is flipping the sign
|
||||||
|
overlap_block_counter++; |
||||||
|
if (overlap_block_counter == 2){ |
||||||
|
overlap_block_counter = 0; |
||||||
|
for (int i=0; i < N_2; i++) { |
||||||
|
complex_2N_buffer[2*i] = -complex_2N_buffer[2*i]; |
||||||
|
complex_2N_buffer[2*i+1] = -complex_2N_buffer[2*i+1]; |
||||||
|
} |
||||||
|
} |
||||||
|
break; |
||||||
|
case THREE_QUARTERS: |
||||||
|
overlap_block_counter++; //will be 1 to 4
|
||||||
|
float foo; |
||||||
|
switch (overlap_block_counter) { |
||||||
|
case 1: |
||||||
|
//no rotation
|
||||||
|
break; |
||||||
|
case 2: |
||||||
|
//90 deg
|
||||||
|
for (int i=0; i < N_2; i++) { |
||||||
|
foo = complex_2N_buffer[2*i+1]; |
||||||
|
complex_2N_buffer[2*i+1] = complex_2N_buffer[2*i]; |
||||||
|
complex_2N_buffer[2*i] = -foo; |
||||||
|
} |
||||||
|
break; |
||||||
|
case 3: |
||||||
|
//180 deg
|
||||||
|
for (int i=0; i < N_2; i++) { |
||||||
|
complex_2N_buffer[2*i] = -complex_2N_buffer[2*i]; |
||||||
|
complex_2N_buffer[2*i+1] = -complex_2N_buffer[2*i+1]; |
||||||
|
} |
||||||
|
break; |
||||||
|
case 4: |
||||||
|
//270 deg
|
||||||
|
for (int i=0; i < N_2; i++) { |
||||||
|
foo = complex_2N_buffer[2*i+1]; |
||||||
|
complex_2N_buffer[2*i+1] = -complex_2N_buffer[2*i]; |
||||||
|
complex_2N_buffer[2*i] = foo; |
||||||
|
} |
||||||
|
overlap_block_counter = 0; |
||||||
|
break;
|
||||||
|
}
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
//zero out the new DC and new nyquist
|
||||||
|
//complex_2N_buffer[0] = 0.0; complex_2N_buffer[1] = 0.0;
|
||||||
|
//complex_2N_buffer[N_2] = 0.0; complex_2N_buffer[N_2] = 0.0;
|
||||||
|
|
||||||
|
//rebuild the negative frequency space
|
||||||
|
myFFT.rebuildNegativeFrequencySpace(complex_2N_buffer); //set the negative frequency space based on the positive
|
||||||
|
|
||||||
|
// ///////////// End do your processing here
|
||||||
|
|
||||||
|
//call the IFFT
|
||||||
|
audio_block_f32_t *out_audio_block = myIFFT.execute(complex_2N_buffer); //out_block is pre-allocated in here.
|
||||||
|
|
||||||
|
//update the block number to match the incoming one
|
||||||
|
out_audio_block->id = incoming_id; |
||||||
|
|
||||||
|
//send the returned audio block. Don't issue the release command here because myIFFT will re-use it
|
||||||
|
AudioStream_F32::transmit(out_audio_block); //don't release this buffer because myIFFT re-uses it within its own code
|
||||||
|
return; |
||||||
|
}; |
@ -0,0 +1,134 @@ |
|||||||
|
/*
|
||||||
|
* AudioEffectFreqShiftFD_F32 |
||||||
|
*
|
||||||
|
* Created: Chip Audette, Aug 2019 |
||||||
|
* Purpose: Shift the frequency content of the audio up or down. Performed in the frequency domain |
||||||
|
*
|
||||||
|
* This processes a single stream of audio data (ie, it is mono)
|
||||||
|
*
|
||||||
|
* MIT License. use at your own risk. |
||||||
|
*/ |
||||||
|
|
||||||
|
#ifndef _AudioEffectFreqShiftFD_OA_F32_h |
||||||
|
#define _AudioEffectFreqShiftFD_OA_F32_h |
||||||
|
|
||||||
|
#include "AudioStream_F32.h" |
||||||
|
#include <arm_math.h> |
||||||
|
#include "FFT_Overlapped_OA_F32.h" |
||||||
|
#include <Arduino.h> |
||||||
|
|
||||||
|
|
||||||
|
class AudioEffectFreqShiftFD_OA_F32 : public AudioStream_F32 |
||||||
|
{ |
||||||
|
//GUI: inputs:1, outputs:1 //this line used for automatic generation of GUI node
|
||||||
|
//GUI: shortName:freq_shift
|
||||||
|
public: |
||||||
|
//constructors...a few different options. The usual one should be: AudioEffectFreqShiftFD_F32(const AudioSettings_F32 &settings, const int _N_FFT)
|
||||||
|
AudioEffectFreqShiftFD_OA_F32(void) : AudioStream_F32(1, inputQueueArray_f32) {}; |
||||||
|
AudioEffectFreqShiftFD_OA_F32(const AudioSettings_F32 &settings) : |
||||||
|
AudioStream_F32(1, inputQueueArray_f32) { |
||||||
|
sample_rate_Hz = settings.sample_rate_Hz; |
||||||
|
} |
||||||
|
AudioEffectFreqShiftFD_OA_F32(const AudioSettings_F32 &settings, const int _N_FFT) : |
||||||
|
AudioStream_F32(1, inputQueueArray_f32) { |
||||||
|
setup(settings, _N_FFT); |
||||||
|
} |
||||||
|
|
||||||
|
//destructor...release all of the memory that has been allocated
|
||||||
|
~AudioEffectFreqShiftFD_OA_F32(void) { |
||||||
|
if (complex_2N_buffer != NULL) delete complex_2N_buffer; |
||||||
|
} |
||||||
|
|
||||||
|
int setup(const AudioSettings_F32 &settings, const int _N_FFT) { |
||||||
|
sample_rate_Hz = settings.sample_rate_Hz; |
||||||
|
|
||||||
|
//setup the FFT and IFFT. If they return a negative FFT, it wasn't an allowed FFT size.
|
||||||
|
N_FFT = myFFT.setup(settings, _N_FFT); //hopefully, we got the same N_FFT that we asked for
|
||||||
|
if (N_FFT < 1) return N_FFT; |
||||||
|
N_FFT = myIFFT.setup(settings, _N_FFT); //hopefully, we got the same N_FFT that we asked for
|
||||||
|
if (N_FFT < 1) return N_FFT; |
||||||
|
|
||||||
|
|
||||||
|
//decide windowing
|
||||||
|
//Serial.println("AudioEffectFreqShiftFD_F32: setting myFFT to use hanning...");
|
||||||
|
(myFFT.getFFTObject())->useHanningWindow(); //applied prior to FFT
|
||||||
|
#if 1 |
||||||
|
if (myIFFT.getNBuffBlocks() > 3) { |
||||||
|
Serial.println("AudioEffectFormantShiftFD_F32: setting myIFFT to use hanning..."); |
||||||
|
(myIFFT.getIFFTObject())->useHanningWindow(); //window again after IFFT
|
||||||
|
} |
||||||
|
#endif |
||||||
|
|
||||||
|
//decide how much overlap is happening
|
||||||
|
switch (myIFFT.getNBuffBlocks()) { |
||||||
|
case 0: |
||||||
|
//should never happen
|
||||||
|
break; |
||||||
|
case 1: |
||||||
|
overlap_amount = NONE; |
||||||
|
break; |
||||||
|
case 2: |
||||||
|
overlap_amount = HALF; |
||||||
|
break; |
||||||
|
case 3: |
||||||
|
//to do...need to add phase shifting logic to the update() function to support this case
|
||||||
|
break; |
||||||
|
case 4: |
||||||
|
overlap_amount = THREE_QUARTERS; |
||||||
|
//to do...need to add phase shifting logic to the update() function to support this case
|
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
#if 0 |
||||||
|
//print info about setup
|
||||||
|
Serial.println("AudioEffectFreqShiftFD_F32: FFT parameters..."); |
||||||
|
Serial.print(" : N_FFT = "); Serial.println(N_FFT); |
||||||
|
Serial.print(" : audio_block_samples = "); Serial.println(settings.audio_block_samples); |
||||||
|
Serial.print(" : FFT N_BUFF_BLOCKS = "); Serial.println(myFFT.getNBuffBlocks()); |
||||||
|
Serial.print(" : IFFT N_BUFF_BLOCKS = "); Serial.println(myIFFT.getNBuffBlocks()); |
||||||
|
Serial.print(" : FFT use window = "); Serial.println(myFFT.getFFTObject()->get_flagUseWindow()); |
||||||
|
Serial.print(" : IFFT use window = "); Serial.println((myIFFT.getIFFTObject())->get_flagUseWindow()); |
||||||
|
#endif |
||||||
|
|
||||||
|
//allocate memory to hold frequency domain data
|
||||||
|
complex_2N_buffer = new float32_t[2 * N_FFT]; |
||||||
|
|
||||||
|
//we're done. return!
|
||||||
|
enabled = 1; |
||||||
|
return N_FFT; |
||||||
|
} |
||||||
|
|
||||||
|
int setShift_bins(int _shift_bins) { |
||||||
|
return shift_bins = _shift_bins; |
||||||
|
} |
||||||
|
int getShift_bins(void) { |
||||||
|
return shift_bins; |
||||||
|
} |
||||||
|
float getShift_Hz(void) { |
||||||
|
return getFrequencyOfBin(shift_bins); |
||||||
|
} |
||||||
|
float getFrequencyOfBin(int bin) { //"bin" should be zero to (N_FFT-1)
|
||||||
|
return sample_rate_Hz * ((float)bin) / ((float) N_FFT); |
||||||
|
} |
||||||
|
|
||||||
|
virtual void update(void); |
||||||
|
bool enable(bool state = true) { enabled = state; return enabled;} |
||||||
|
|
||||||
|
private: |
||||||
|
int enabled = 0; |
||||||
|
float32_t *complex_2N_buffer; |
||||||
|
audio_block_f32_t *inputQueueArray_f32[1]; |
||||||
|
FFT_Overlapped_OA_F32 myFFT; |
||||||
|
IFFT_Overlapped_OA_F32 myIFFT; |
||||||
|
float sample_rate_Hz = AUDIO_SAMPLE_RATE; |
||||||
|
int N_FFT = -1; |
||||||
|
enum OVERLAP_OPTIONS {NONE, HALF, THREE_QUARTERS}; //evenutally extend to THREE_QUARTERS
|
||||||
|
int overlap_amount = NONE; |
||||||
|
int overlap_block_counter = 0; |
||||||
|
|
||||||
|
int shift_bins = 0; //how much to shift the frequency
|
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
#endif |
@ -0,0 +1,179 @@ |
|||||||
|
/* FreqShifter_FD_OA.ino
|
||||||
|
* |
||||||
|
* Demonstrate frequency shifting via frequency domain processin. |
||||||
|
* |
||||||
|
* Created: Chip Audette (OpenAudio) Aug 2019 |
||||||
|
* |
||||||
|
* Approach: This processing is performed in the frequency domain. |
||||||
|
* Frequencies can only be shifted by an integer number of bins, |
||||||
|
* so small frequency shifts are not possible. For example, for |
||||||
|
* a sample rate of 44.1kHz, and when using N=256, one can only |
||||||
|
* shift frequencies in multiples of 44.1/256 = 172.3 Hz. |
||||||
|
* |
||||||
|
* This processing is performed in the frequency domain where |
||||||
|
* we take the FFT, shift the bins upward or downward, take |
||||||
|
* the IFFT, and listen to the results. In effect, this is |
||||||
|
* single sideband modulation, which will sound very unnatural |
||||||
|
* (like robot voices!). Maybe you'll like it, or maybe not. |
||||||
|
* Probably not, unless you like weird. ;) |
||||||
|
* |
||||||
|
* You can shift frequencies upward or downward with this algorithm. |
||||||
|
* |
||||||
|
* Frequency Domain Processing: |
||||||
|
* * Take samples in the time domain |
||||||
|
* * Take FFT to convert to frequency domain |
||||||
|
* * Manipulate the frequency bins to do the freqyebct shifting |
||||||
|
* * Take IFFT to convert back to time domain |
||||||
|
* * Send samples back to the audio interface |
||||||
|
* |
||||||
|
* Built for the Tympan library for Teensy 3.6-based hardware |
||||||
|
* |
||||||
|
* Convert to Open Audio Bob Larkin June 2020 |
||||||
|
* Tested OK for Teensy 3.6 and 4.0. |
||||||
|
* For settings: |
||||||
|
* sample rate (Hz) = 44117.00 |
||||||
|
* block size (samples) = 128 |
||||||
|
* N_FFT = 512 |
||||||
|
* the following resources were used for Teensy 3.6 |
||||||
|
* CPU Cur/Peak: 50.02%/50.24% |
||||||
|
* MEM Float32 Cur/Peak: 8/9 |
||||||
|
* MEM Int16 Cur/Peak: 3/5 |
||||||
|
* For Teensy 4.0: |
||||||
|
* CPU Cur/Peak: 6.53%/6.84% |
||||||
|
* MEM Float32 Cur/Peak: 8/9 |
||||||
|
* MEM Int16 Cur/Peak: 3/4 |
||||||
|
* |
||||||
|
* MIT License. Use at your own risk. |
||||||
|
*/ |
||||||
|
|
||||||
|
#include "Audio.h" |
||||||
|
#include "AudioStream_F32.h" |
||||||
|
#include "OpenAudio_ArduinoLibrary.h" |
||||||
|
#include "SerialManager_FreqShift_OA.h" |
||||||
|
|
||||||
|
//set the sample rate and block size
|
||||||
|
const float sample_rate_Hz = 44117.f; ; // other frequencies in the table in AudioOutputI2S_F32 for T3.x only
|
||||||
|
const int audio_block_samples = 128; //for freq domain processing, a power of 2: 16, 32, 64, 128
|
||||||
|
AudioSettings_F32 audio_settings(sample_rate_Hz, audio_block_samples); |
||||||
|
|
||||||
|
//create audio library objects for handling the audio
|
||||||
|
AudioInputI2S i2sIn; // This I16 input/output is T4.x compatible
|
||||||
|
AudioConvert_I16toF32 cnvrt1; // Convert to float
|
||||||
|
AudioEffectFreqShiftFD_OA_F32 freqShift(audio_settings); // the frequency-domain processing block
|
||||||
|
AudioEffectGain_F32 gain1; //Applies digital gain to audio data.
|
||||||
|
AudioConvert_F32toI16 cnvrt2; |
||||||
|
AudioOutputI2S i2sOut; |
||||||
|
AudioControlSGTL5000 codec; |
||||||
|
//Make all of the audio connections
|
||||||
|
AudioConnection patchCord1(i2sIn, 0, cnvrt1, 0); // connect to Left codec, 16-bit
|
||||||
|
AudioConnection_F32 patchCord2(cnvrt1, 0, freqShift, 0); |
||||||
|
AudioConnection_F32 patchCord3(freqShift, 0, gain1, 0); //connect to gain
|
||||||
|
AudioConnection_F32 patchCord4(gain1, 0, cnvrt2, 0); //connect to the left output
|
||||||
|
AudioConnection patchCord6(cnvrt2, 0, i2sOut, 0); |
||||||
|
|
||||||
|
//control display and serial interaction
|
||||||
|
bool enable_printCPUandMemory = false; |
||||||
|
void togglePrintMemoryAndCPU(void) { enable_printCPUandMemory = !enable_printCPUandMemory; }; |
||||||
|
SerialManagerFreqShift_OA serialMgr; |
||||||
|
|
||||||
|
//inputs and levels
|
||||||
|
float input_gain_dB = 15.0f; //gain on the microphone
|
||||||
|
float vol_knob_gain_dB = 0.0; //will be overridden by volume knob
|
||||||
|
|
||||||
|
// *************** SETUP **********************************
|
||||||
|
void setup() { |
||||||
|
Serial.begin(1); delay(1000); |
||||||
|
Serial.println("freqShifter: starting setup()..."); |
||||||
|
Serial.print(" : sample rate (Hz) = "); Serial.println(audio_settings.sample_rate_Hz); |
||||||
|
Serial.print(" : block size (samples) = "); Serial.println(audio_settings.audio_block_samples); |
||||||
|
|
||||||
|
// Audio connections require memory to work. For more
|
||||||
|
// detailed information, see the MemoryAndCpuUsage example
|
||||||
|
AudioMemory(10); // I16 type
|
||||||
|
AudioMemory_F32(40, audio_settings); |
||||||
|
|
||||||
|
// Configure the FFT parameters algorithm
|
||||||
|
int overlap_factor = 4; //set to 2, 4 or 8...which yields 50%, 75%, or 87.5% overlap (8x)
|
||||||
|
int N_FFT = audio_block_samples * overlap_factor;
|
||||||
|
Serial.print(" : N_FFT = "); Serial.println(N_FFT); |
||||||
|
freqShift.setup(audio_settings, N_FFT); //do after AudioMemory_F32();
|
||||||
|
|
||||||
|
//configure the frequency shifting
|
||||||
|
float shiftFreq_Hz = 750.0; //shift audio upward a bit
|
||||||
|
float Hz_per_bin = audio_settings.sample_rate_Hz / ((float)N_FFT); |
||||||
|
int shift_bins = (int)(shiftFreq_Hz / Hz_per_bin + 0.5); //round to nearest bin
|
||||||
|
|
||||||
|
shiftFreq_Hz = shift_bins * Hz_per_bin; |
||||||
|
Serial.print("Setting shift to "); Serial.print(shiftFreq_Hz); |
||||||
|
Serial.print(" Hz, which is "); Serial.print(shift_bins);
|
||||||
|
Serial.println(" bins"); |
||||||
|
freqShift.setShift_bins(shift_bins); //0 is no ffreq shifting.
|
||||||
|
|
||||||
|
//Enable the Tympan to start the audio flowing!
|
||||||
|
codec.enable(); |
||||||
|
codec.adcHighPassFilterEnable(); |
||||||
|
codec.inputSelect(AUDIO_INPUT_LINEIN); |
||||||
|
|
||||||
|
//finish the setup by printing the help menu to the serial connections
|
||||||
|
serialMgr.printHelp(); |
||||||
|
} |
||||||
|
|
||||||
|
// ************************* LOOP ****************************
|
||||||
|
void loop() { |
||||||
|
|
||||||
|
//respond to Serial commands
|
||||||
|
while (Serial.available()) serialMgr.respondToByte((char)Serial.read()); //USB Serial
|
||||||
|
|
||||||
|
//check to see whether to print the CPU and Memory Usage
|
||||||
|
if (enable_printCPUandMemory) printCPUandMemory(millis(), 3000); //print every 3000 msec
|
||||||
|
|
||||||
|
} //end loop();
|
||||||
|
|
||||||
|
//This routine prints the current and maximum CPU usage and the current usage of the AudioMemory that has been allocated
|
||||||
|
void printCPUandMemory(unsigned long curTime_millis, unsigned long updatePeriod_millis) { |
||||||
|
//static unsigned long updatePeriod_millis = 3000; //how many milliseconds between updating gain reading?
|
||||||
|
static unsigned long lastUpdate_millis = 0; |
||||||
|
|
||||||
|
//has enough time passed to update everything?
|
||||||
|
if (curTime_millis < lastUpdate_millis) lastUpdate_millis = 0; //handle wrap-around of the clock
|
||||||
|
if ((curTime_millis - lastUpdate_millis) > updatePeriod_millis) { //is it time to update the user interface?
|
||||||
|
Serial.print("CPU Cur/Peak: "); |
||||||
|
Serial.print(audio_settings.processorUsage()); |
||||||
|
Serial.print("%/"); |
||||||
|
Serial.print(audio_settings.processorUsageMax()); |
||||||
|
Serial.print("%, "); |
||||||
|
Serial.print(" Dyn MEM Float32 Cur/Peak: "); |
||||||
|
Serial.print(AudioMemoryUsage_F32()); |
||||||
|
Serial.print("/"); |
||||||
|
Serial.print(AudioMemoryUsageMax_F32()); |
||||||
|
Serial.print(" MEM Int16 Cur/Peak: "); |
||||||
|
Serial.print(AudioMemoryUsage()); |
||||||
|
Serial.print("/"); |
||||||
|
Serial.print(AudioMemoryUsageMax()); |
||||||
|
Serial.println(); |
||||||
|
|
||||||
|
lastUpdate_millis = curTime_millis; //we will use this value the next time around.
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void printGainSettings(void) { |
||||||
|
Serial.print("Gain (dB): "); |
||||||
|
Serial.print("Vol Knob = "); Serial.print(vol_knob_gain_dB,1); |
||||||
|
//Serial.print(", Input PGA = "); Serial.print(input_gain_dB,1);
|
||||||
|
Serial.println(); |
||||||
|
} |
||||||
|
|
||||||
|
void incrementKnobGain(float increment_dB) { |
||||||
|
setVolKnobGain_dB(vol_knob_gain_dB+increment_dB); |
||||||
|
} |
||||||
|
|
||||||
|
void setVolKnobGain_dB(float gain_dB) { |
||||||
|
vol_knob_gain_dB = gain_dB; |
||||||
|
gain1.setGain_dB(vol_knob_gain_dB); |
||||||
|
printGainSettings(); |
||||||
|
} |
||||||
|
|
||||||
|
int incrementFreqShift(int incr_factor) { |
||||||
|
int cur_shift_bins = freqShift.getShift_bins(); |
||||||
|
return freqShift.setShift_bins(cur_shift_bins + incr_factor); |
||||||
|
} |
@ -1,226 +0,0 @@ |
|||||||
// FreqShifter_FD
|
|
||||||
//
|
|
||||||
// Demonstrate frequency shifting via frequency domain processin.
|
|
||||||
//
|
|
||||||
// Created: Chip Audette (OpenAudio) Aug 2019
|
|
||||||
//
|
|
||||||
// Approach: This processing is performed in the frequency domain.
|
|
||||||
// Frequencies can only be shifted by an integer number of bins,
|
|
||||||
// so small frequency shifts are not possible. For example, for
|
|
||||||
// a sample rate of 44.1kHz, and when using N=256, one can only
|
|
||||||
// shift frequencies in multiples of 44.1/256 = 172.3 Hz.
|
|
||||||
//
|
|
||||||
// This processing is performed in the frequency domain where
|
|
||||||
// we take the FFT, shift the bins upward or downward, take
|
|
||||||
// the IFFT, and listen to the results. In effect, this is
|
|
||||||
// single sideband modulation, which will sound very unnatural
|
|
||||||
// (like robot voices!). Maybe you'll like it, or maybe not.
|
|
||||||
// Probably not, unless you like weird. ;)
|
|
||||||
//
|
|
||||||
// You can shift frequencies upward or downward with this algorithm.
|
|
||||||
//
|
|
||||||
// Frequency Domain Processing:
|
|
||||||
// * Take samples in the time domain
|
|
||||||
// * Take FFT to convert to frequency domain
|
|
||||||
// * Manipulate the frequency bins to do the freqyebct shifting
|
|
||||||
// * Take IFFT to convert back to time domain
|
|
||||||
// * Send samples back to the audio interface
|
|
||||||
//
|
|
||||||
// Built for the Tympan library for Teensy 3.6-based hardware
|
|
||||||
//
|
|
||||||
// MIT License. Use at your own risk.
|
|
||||||
//
|
|
||||||
|
|
||||||
#include <Tympan_Library.h> |
|
||||||
#include "SerialManager.h" |
|
||||||
|
|
||||||
//set the sample rate and block size
|
|
||||||
const float sample_rate_Hz = 44117.f; ; //24000 or 44117 (or other frequencies in the table in AudioOutputI2S_F32)
|
|
||||||
const int audio_block_samples = 64; //for freq domain processing choose a power of 2 (16, 32, 64, 128) but no higher than 128
|
|
||||||
AudioSettings_F32 audio_settings(sample_rate_Hz, audio_block_samples); |
|
||||||
|
|
||||||
//create audio library objects for handling the audio
|
|
||||||
Tympan audioHardware(TympanRev::D); //do TympanRev::C or TympanRev::D
|
|
||||||
AudioInputI2S_F32 i2s_in(audio_settings); //Digital audio *from* the Tympan AIC.
|
|
||||||
AudioEffectFreqShiftFD_F32 freqShift(audio_settings); //create the frequency-domain processing block
|
|
||||||
AudioEffectGain_F32 gain1; //Applies digital gain to audio data.
|
|
||||||
AudioOutputI2S_F32 i2s_out(audio_settings); //Digital audio out *to* the Tympan AIC.
|
|
||||||
|
|
||||||
//Make all of the audio connections
|
|
||||||
AudioConnection_F32 patchCord10(i2s_in, 0, freqShift, 0); //use the Left input
|
|
||||||
AudioConnection_F32 patchCord20(freqShift, 0, gain1, 0); //connect to gain
|
|
||||||
AudioConnection_F32 patchCord30(gain1, 0, i2s_out, 0); //connect to the left output
|
|
||||||
AudioConnection_F32 patchCord40(gain1, 0, i2s_out, 1); //connect to the right output
|
|
||||||
|
|
||||||
|
|
||||||
//control display and serial interaction
|
|
||||||
bool enable_printCPUandMemory = false; |
|
||||||
void togglePrintMemoryAndCPU(void) { enable_printCPUandMemory = !enable_printCPUandMemory; }; |
|
||||||
SerialManager serialManager(audioHardware); |
|
||||||
#define mySerial audioHardware //audioHardware is a printable stream!
|
|
||||||
|
|
||||||
//inputs and levels
|
|
||||||
float input_gain_dB = 15.0f; //gain on the microphone
|
|
||||||
float vol_knob_gain_dB = 0.0; //will be overridden by volume knob
|
|
||||||
void switchToPCBMics(void) { |
|
||||||
mySerial.println("Switching to PCB Mics."); |
|
||||||
audioHardware.inputSelect(TYMPAN_INPUT_ON_BOARD_MIC); // use the microphone jack - defaults to mic bias OFF
|
|
||||||
audioHardware.setInputGain_dB(input_gain_dB); |
|
||||||
} |
|
||||||
void switchToLineInOnMicJack(void) { |
|
||||||
mySerial.println("Switching to Line-in on Mic Jack."); |
|
||||||
audioHardware.inputSelect(TYMPAN_INPUT_JACK_AS_LINEIN); // use the microphone jack - defaults to mic bias OFF
|
|
||||||
audioHardware.setInputGain_dB(0.0); |
|
||||||
} |
|
||||||
void switchToMicInOnMicJack(void) { |
|
||||||
mySerial.println("Switching to Mic-In on Mic Jack."); |
|
||||||
audioHardware.inputSelect(TYMPAN_INPUT_JACK_AS_MIC); // use the microphone jack - defaults to mic bias OFF
|
|
||||||
audioHardware.setEnableStereoExtMicBias(true); //put the mic bias on both channels
|
|
||||||
audioHardware.setInputGain_dB(input_gain_dB); |
|
||||||
} |
|
||||||
|
|
||||||
// define the setup() function, the function that is called once when the device is booting
|
|
||||||
void setup() { |
|
||||||
audioHardware.beginBothSerial(); delay(1000); |
|
||||||
mySerial.println("freqShifter: starting setup()..."); |
|
||||||
mySerial.print(" : sample rate (Hz) = "); mySerial.println(audio_settings.sample_rate_Hz); |
|
||||||
mySerial.print(" : block size (samples) = "); mySerial.println(audio_settings.audio_block_samples); |
|
||||||
|
|
||||||
// Audio connections require memory to work. For more
|
|
||||||
// detailed information, see the MemoryAndCpuUsage example
|
|
||||||
AudioMemory_F32(40, audio_settings); |
|
||||||
|
|
||||||
// Configure the FFT parameters algorithm
|
|
||||||
int overlap_factor = 4; //set to 2, 4 or 8...which yields 50%, 75%, or 87.5% overlap (8x)
|
|
||||||
int N_FFT = audio_block_samples * overlap_factor;
|
|
||||||
Serial.print(" : N_FFT = "); Serial.println(N_FFT); |
|
||||||
freqShift.setup(audio_settings, N_FFT); //do after AudioMemory_F32();
|
|
||||||
|
|
||||||
//configure the frequency shifting
|
|
||||||
float shiftFreq_Hz = 750.0; //shift audio upward a bit
|
|
||||||
float Hz_per_bin = audio_settings.sample_rate_Hz / ((float)N_FFT); |
|
||||||
int shift_bins = (int)(shiftFreq_Hz / Hz_per_bin + 0.5); //round to nearest bin
|
|
||||||
shiftFreq_Hz = shift_bins * Hz_per_bin; |
|
||||||
Serial.print("Setting shift to "); Serial.print(shiftFreq_Hz); Serial.print(" Hz, which is "); Serial.print(shift_bins); Serial.println(" bins"); |
|
||||||
freqShift.setShift_bins(shift_bins); //0 is no ffreq shifting.
|
|
||||||
|
|
||||||
//Enable the Tympan to start the audio flowing!
|
|
||||||
audioHardware.enable(); // activate AIC
|
|
||||||
|
|
||||||
//setup DC-blocking highpass filter running in the ADC hardware itself
|
|
||||||
float cutoff_Hz = 60.0; //set the default cutoff frequency for the highpass filter
|
|
||||||
audioHardware.setHPFonADC(true,cutoff_Hz,audio_settings.sample_rate_Hz); //set to false to disble
|
|
||||||
|
|
||||||
//Choose the desired input
|
|
||||||
switchToPCBMics(); //use PCB mics as input
|
|
||||||
//switchToMicInOnMicJack(); //use Mic jack as mic input (ie, with mic bias)
|
|
||||||
//switchToLineInOnMicJack(); //use Mic jack as line input (ie, no mic bias)
|
|
||||||
|
|
||||||
//Set the desired volume levels
|
|
||||||
audioHardware.volume_dB(0); // headphone amplifier. -63.6 to +24 dB in 0.5dB steps.
|
|
||||||
|
|
||||||
// configure the blue potentiometer
|
|
||||||
servicePotentiometer(millis(),0); //update based on the knob setting the "0" is not relevant here.
|
|
||||||
|
|
||||||
//finish the setup by printing the help menu to the serial connections
|
|
||||||
serialManager.printHelp(); |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
// define the loop() function, the function that is repeated over and over for the life of the device
|
|
||||||
void loop() { |
|
||||||
|
|
||||||
//respond to Serial commands
|
|
||||||
while (Serial.available()) serialManager.respondToByte((char)Serial.read()); //USB Serial
|
|
||||||
//while (Serial1.available()) serialManager.respondToByte((char)Serial1.read()); //BT Serial
|
|
||||||
|
|
||||||
//check the potentiometer
|
|
||||||
servicePotentiometer(millis(), 100); //service the potentiometer every 100 msec
|
|
||||||
|
|
||||||
//check to see whether to print the CPU and Memory Usage
|
|
||||||
if (enable_printCPUandMemory) printCPUandMemory(millis(), 3000); //print every 3000 msec
|
|
||||||
|
|
||||||
} //end loop();
|
|
||||||
|
|
||||||
|
|
||||||
// ///////////////// Servicing routines
|
|
||||||
|
|
||||||
//servicePotentiometer: listens to the blue potentiometer and sends the new pot value
|
|
||||||
// to the audio processing algorithm as a control parameter
|
|
||||||
void servicePotentiometer(unsigned long curTime_millis, const unsigned long updatePeriod_millis) { |
|
||||||
//static unsigned long updatePeriod_millis = 100; //how many milliseconds between updating the potentiometer reading?
|
|
||||||
static unsigned long lastUpdate_millis = 0; |
|
||||||
static float prev_val = -1.0; |
|
||||||
|
|
||||||
//has enough time passed to update everything?
|
|
||||||
if (curTime_millis < lastUpdate_millis) lastUpdate_millis = 0; //handle wrap-around of the clock
|
|
||||||
if ((curTime_millis - lastUpdate_millis) > updatePeriod_millis) { //is it time to update the user interface?
|
|
||||||
|
|
||||||
//read potentiometer
|
|
||||||
float val = float(audioHardware.readPotentiometer()) / 1023.0; //0.0 to 1.0
|
|
||||||
val = (1.0/9.0) * (float)((int)(9.0 * val + 0.5)); //quantize so that it doesn't chatter...0 to 1.0
|
|
||||||
|
|
||||||
//use the potentiometer value to control something interesting
|
|
||||||
if (abs(val - prev_val) > 0.05) { //is it different than befor?
|
|
||||||
prev_val = val; //save the value for comparison for the next time around
|
|
||||||
|
|
||||||
//change the volume
|
|
||||||
float vol_dB = 0.f + 30.0f * ((val - 0.5) * 2.0); //set volume as 0dB +/- 30 dB
|
|
||||||
audioHardware.print("Changing output volume to = "); audioHardware.print(vol_dB); audioHardware.println(" dB"); |
|
||||||
audioHardware.volume_dB(vol_dB); |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
lastUpdate_millis = curTime_millis; |
|
||||||
} // end if
|
|
||||||
} //end servicePotentiometer();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//This routine prints the current and maximum CPU usage and the current usage of the AudioMemory that has been allocated
|
|
||||||
void printCPUandMemory(unsigned long curTime_millis, unsigned long updatePeriod_millis) { |
|
||||||
//static unsigned long updatePeriod_millis = 3000; //how many milliseconds between updating gain reading?
|
|
||||||
static unsigned long lastUpdate_millis = 0; |
|
||||||
|
|
||||||
//has enough time passed to update everything?
|
|
||||||
if (curTime_millis < lastUpdate_millis) lastUpdate_millis = 0; //handle wrap-around of the clock
|
|
||||||
if ((curTime_millis - lastUpdate_millis) > updatePeriod_millis) { //is it time to update the user interface?
|
|
||||||
mySerial.print("printCPUandMemory: "); |
|
||||||
mySerial.print("CPU Cur/Peak: "); |
|
||||||
mySerial.print(audio_settings.processorUsage()); |
|
||||||
mySerial.print("%/"); |
|
||||||
mySerial.print(audio_settings.processorUsageMax()); |
|
||||||
mySerial.print("%, "); |
|
||||||
mySerial.print("Dyn MEM Float32 Cur/Peak: "); |
|
||||||
mySerial.print(AudioMemoryUsage_F32()); |
|
||||||
mySerial.print("/"); |
|
||||||
mySerial.print(AudioMemoryUsageMax_F32()); |
|
||||||
mySerial.println(); |
|
||||||
|
|
||||||
lastUpdate_millis = curTime_millis; //we will use this value the next time around.
|
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void printGainSettings(void) { |
|
||||||
mySerial.print("Gain (dB): "); |
|
||||||
mySerial.print("Vol Knob = "); mySerial.print(vol_knob_gain_dB,1); |
|
||||||
//mySerial.print(", Input PGA = "); mySerial.print(input_gain_dB,1);
|
|
||||||
mySerial.println(); |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
void incrementKnobGain(float increment_dB) { //"extern" to make it available to other files, such as SerialManager.h
|
|
||||||
setVolKnobGain_dB(vol_knob_gain_dB+increment_dB); |
|
||||||
} |
|
||||||
|
|
||||||
void setVolKnobGain_dB(float gain_dB) { |
|
||||||
vol_knob_gain_dB = gain_dB; |
|
||||||
gain1.setGain_dB(vol_knob_gain_dB); |
|
||||||
printGainSettings(); |
|
||||||
} |
|
||||||
|
|
||||||
int incrementFreqShift(int incr_factor) { |
|
||||||
int cur_shift_bins = freqShift.getShift_bins(); |
|
||||||
return freqShift.setShift_bins(cur_shift_bins + incr_factor); |
|
||||||
} |
|
@ -0,0 +1,73 @@ |
|||||||
|
// SerialManager_FreqShift_OA.h
|
||||||
|
// Demonstrate frequency shifting via frequency domain processing.
|
||||||
|
//
|
||||||
|
// Created: Chip Audette (OpenAudio) Aug 2019
|
||||||
|
// Built for the Tympan library for Teensy 3.6-based hardware
|
||||||
|
//
|
||||||
|
// Convert to Open Audio Bob Larkin June 2020
|
||||||
|
//
|
||||||
|
// MIT License. Use at your own risk.
|
||||||
|
//
|
||||||
|
|
||||||
|
#ifndef _SerialManagerFreqShift_OA_h |
||||||
|
#define _SerialManagerFreqShift_OA_h |
||||||
|
#include "AudioStream_F32.h" |
||||||
|
#include "OpenAudio_ArduinoLibrary.h" |
||||||
|
//now, define the Serial Manager class
|
||||||
|
class SerialManagerFreqShift_OA { |
||||||
|
public: |
||||||
|
SerialManagerFreqShift_OA(void) { }; |
||||||
|
|
||||||
|
void respondToByte(char c); |
||||||
|
void printHelp(void); |
||||||
|
int N_CHAN; |
||||||
|
float channelGainIncrement_dB = 2.5f;
|
||||||
|
int freq_shift_increment = 1; |
||||||
|
}; |
||||||
|
|
||||||
|
void SerialManagerFreqShift_OA::printHelp(void) {
|
||||||
|
Serial.println(); |
||||||
|
Serial.println("SerialManager Help: Available Commands:"); |
||||||
|
Serial.println(" h: Print this help"); |
||||||
|
Serial.println(" g: Print the gain settings of the device."); |
||||||
|
Serial.println(" C: Toggle printing of CPU and Memory usage"); |
||||||
|
Serial.print( " k: Increase the gain of all channels (ie, knob gain) by "); |
||||||
|
Serial.print(channelGainIncrement_dB); Serial.println(" dB"); |
||||||
|
Serial.print( " K: Decrease the gain of all channels (ie, knob gain) by "); |
||||||
|
Serial.print(-channelGainIncrement_dB); Serial.println(" dB"); |
||||||
|
Serial.print( " f: Raise freq shifting (change by "); Serial.print(freq_shift_increment); Serial.println(" bins)"); |
||||||
|
Serial.print( " F: Lower freq shifting (change by "); Serial.print(-freq_shift_increment); Serial.println(" bins)"); Serial.println(); |
||||||
|
} |
||||||
|
|
||||||
|
//functions in the main sketch that I want to call from here
|
||||||
|
extern void incrementKnobGain(float); |
||||||
|
extern void printGainSettings(void); |
||||||
|
extern void togglePrintMemoryAndCPU(void); |
||||||
|
extern int incrementFreqShift(int); |
||||||
|
|
||||||
|
//switch yard to determine the desired action
|
||||||
|
void SerialManagerFreqShift_OA::respondToByte(char c) { |
||||||
|
//float old_val = 0.0, new_val = 0.0;
|
||||||
|
switch (c) { |
||||||
|
case 'h': case '?': |
||||||
|
printHelp(); break; |
||||||
|
case 'g': case 'G': |
||||||
|
printGainSettings(); break; |
||||||
|
case 'k': |
||||||
|
incrementKnobGain(channelGainIncrement_dB); break; |
||||||
|
case 'K': //which is "shift k"
|
||||||
|
incrementKnobGain(-channelGainIncrement_dB); break; |
||||||
|
case 'C': case 'c': |
||||||
|
Serial.println("Received: toggle printing of memory and CPU usage."); |
||||||
|
togglePrintMemoryAndCPU(); break; |
||||||
|
case 'f': |
||||||
|
{ int new_val = incrementFreqShift(freq_shift_increment); |
||||||
|
Serial.print("Recieved: new freq shift = "); Serial.println(new_val);} |
||||||
|
break; |
||||||
|
case 'F': |
||||||
|
{ int new_val = incrementFreqShift(-freq_shift_increment); |
||||||
|
Serial.print("Recieved: new freq shift = "); Serial.println(new_val);} |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
#endif |
@ -1,87 +0,0 @@ |
|||||||
|
|
||||||
|
|
||||||
#ifndef _SerialManager_h |
|
||||||
#define _SerialManager_h |
|
||||||
|
|
||||||
#include <Tympan_Library.h> |
|
||||||
|
|
||||||
|
|
||||||
//now, define the Serial Manager class
|
|
||||||
class SerialManager { |
|
||||||
public: |
|
||||||
public: |
|
||||||
SerialManager(Tympan &_audioHardware) |
|
||||||
: audioHardware(_audioHardware) |
|
||||||
{ }; |
|
||||||
//SerialManager(void)
|
|
||||||
//{ };
|
|
||||||
|
|
||||||
void respondToByte(char c); |
|
||||||
void printHelp(void); |
|
||||||
int N_CHAN; |
|
||||||
float channelGainIncrement_dB = 2.5f;
|
|
||||||
int freq_shift_increment = 1; |
|
||||||
|
|
||||||
private: |
|
||||||
Tympan &audioHardware; |
|
||||||
}; |
|
||||||
#define thisSerial audioHardware |
|
||||||
|
|
||||||
void SerialManager::printHelp(void) {
|
|
||||||
thisSerial.println(); |
|
||||||
thisSerial.println("SerialManager Help: Available Commands:"); |
|
||||||
thisSerial.println(" h: Print this help"); |
|
||||||
thisSerial.println(" g: Print the gain settings of the device."); |
|
||||||
thisSerial.println(" C: Toggle printing of CPU and Memory usage"); |
|
||||||
thisSerial.println(" p: Switch to built-in PCB microphones"); |
|
||||||
thisSerial.println(" m: switch to external mic via mic jack"); |
|
||||||
thisSerial.println(" l: switch to line-in via mic jack"); |
|
||||||
thisSerial.print(" k: Increase the gain of all channels (ie, knob gain) by "); thisSerial.print(channelGainIncrement_dB); thisSerial.println(" dB"); |
|
||||||
thisSerial.print(" K: Decrease the gain of all channels (ie, knob gain) by ");thisSerial.print(-channelGainIncrement_dB); thisSerial.println(" dB"); |
|
||||||
thisSerial.print(" f: Raise freq shifting (change by "); thisSerial.print(freq_shift_increment); thisSerial.println(" bins)"); |
|
||||||
thisSerial.print(" F: Lower freq shifting (change by "); thisSerial.print(-freq_shift_increment); thisSerial.println(" bins)"); thisSerial.println(); |
|
||||||
} |
|
||||||
|
|
||||||
//functions in the main sketch that I want to call from here
|
|
||||||
extern void incrementKnobGain(float); |
|
||||||
extern void printGainSettings(void); |
|
||||||
extern void togglePrintMemoryAndCPU(void); |
|
||||||
extern int incrementFreqShift(int); |
|
||||||
extern void switchToPCBMics(void); |
|
||||||
extern void switchToMicInOnMicJack(void); |
|
||||||
extern void switchToLineInOnMicJack(void); |
|
||||||
|
|
||||||
//switch yard to determine the desired action
|
|
||||||
void SerialManager::respondToByte(char c) { |
|
||||||
//float old_val = 0.0, new_val = 0.0;
|
|
||||||
switch (c) { |
|
||||||
case 'h': case '?': |
|
||||||
printHelp(); break; |
|
||||||
case 'g': case 'G': |
|
||||||
printGainSettings(); break; |
|
||||||
case 'k': |
|
||||||
incrementKnobGain(channelGainIncrement_dB); break; |
|
||||||
case 'K': //which is "shift k"
|
|
||||||
incrementKnobGain(-channelGainIncrement_dB); break; |
|
||||||
case 'C': case 'c': |
|
||||||
thisSerial.println("Received: toggle printing of memory and CPU usage."); |
|
||||||
togglePrintMemoryAndCPU(); break; |
|
||||||
case 'p': |
|
||||||
switchToPCBMics(); break; |
|
||||||
case 'm': |
|
||||||
switchToMicInOnMicJack(); break; |
|
||||||
case 'l': |
|
||||||
switchToLineInOnMicJack();break; |
|
||||||
case 'f': |
|
||||||
{ int new_val = incrementFreqShift(freq_shift_increment); |
|
||||||
thisSerial.print("Recieved: new freq shift = "); thisSerial.println(new_val);} |
|
||||||
break; |
|
||||||
case 'F': |
|
||||||
{ int new_val = incrementFreqShift(-freq_shift_increment); |
|
||||||
thisSerial.print("Recieved: new freq shift = "); thisSerial.println(new_val);} |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
#endif |
|
Loading…
Reference in new issue