📜 ⬆️ ⬇️

Software synthesizer

And so gentlemen, I finally decided to deal with software synthesis of music, namely with the practical part of the implementation of such a task. Let's see what came of it and how it is implemented ...



Create waves


We all perfectly understand that sound is a wave and that the frequency of oscillations of a wave from zero level corresponds to the frequency of a sound wave, and the amplitude of this wave is responsible for its strength or simply speaking loudness, but in the machine representation the sound recorded as pulse code modulation is an array of data , each element of which represents the position of the wave at a specific point in time.
Let's consider a simple sound wave in PCM format better, for this we first write a function that will be a model of a sine wave. It will take two values ​​- the offset and frequency.

public static double Sine(int index, double frequency) { return Math.Sin(frequency * index); } 

And now we add it to the Program class and write the main function Main which will initialize an array of data of 75 elements in length which will represent our sound and cyclically fill each cell using the model of the sinusoid we just wrote. To calculate the value of a function for a specific offset, we need to take into account the sinusoid period equal to 2 * Pi and multiply this period by the wave frequency we need. But in order to understand what the resulting frequency sound will come out in PCM format, you need to know its sampling rate. The sampling frequency is the sampling rate of elements per unit of time, well, if it is completely simplified, then this is the number of array elements per second, which means that the frequency of sound in PCM format is the frequency of the wave divided by the frequency of its sampling. Let's generate a sound wave with a frequency of 2 Hz. In this case, we agree that the sampling frequency will be 75 Hz.
')
 class Program { public static void Main(string[] args) { double[] data = new double[75]; //  . for (int index = 1; index < 76; index++) { //     . data[index-1] = Sine(index, Math.PI * 2 * 2.0 / 75); //     . } Console.ReadKey(true); //    . } public static double Sine(int index, double frequency) { return Math.Sin(frequency * index); } } 

And now, in order to see the result of our work, we will add to the Program class a new function capable of visualizing our function right in the console (This will be the fastest way), so we will not go into its details.

 public static void Draw (double[] data) { Console.BufferHeight = 25; //        . Console.CursorVisible = false; //    . for (int y = 0; y < 19; y++) {//    . Console.SetCursorPosition(77, y + 5);//     . Console.Write(9 - y); //    . } for (int x = 0; x < 75; x++) { //     Console.SetCursorPosition(x, x % 3); //    . Console.Write(x + 1); //   . int point = (int)(data[x] * 9); //         -9  9. int step = (point > 0)? -1 : 1; //     0. for (int y = point; y != step; y += step) {//   Console.SetCursorPosition(x, point + 14 - y); //    . Console.Write("█"); //  . } } } 

Now we can see what our two hertz looks like in a machine representation.



But this is only one type of wave while there are many other types of waves, let's describe the functions capable of simulating the main types of waves and consider how they look.

 private static double Saw(int index, double frequency) { return 2.0 * (index * frequency - Math.Floor(index * frequency )) -1.0; } 

Saw function result


 private static double Triangle(int index, double frequency) { return 2.0 * Math.Abs (2.0 * (index * frequency - Math.Floor(index * frequency + 0.5))) - 1.0; } 

Triangle function result

 private static double Flat(int index, double frequency) { if (Math.Sin(frequency * index ) > 0) return 1; else return -1; } 

The result of the function Flat

Note that the period of the Sine and Flat functions is 2 * Pi, and the period of the Saw and Triangle functions is one.

Write the Wav file


When we were able to create and even consider our sound, we would also like to hear it and for that let's record it in the .wav container and listen. True, until we know how the Wave container works, we need to correct this annoying mistake! So, the wave file is very simple: it consists of three parts, the first is the block header, the second is the format block, the third is the data block. All together it looks like this:

Actually everything is very clear, the details on each of the items are described here . Since our task is extremely simple, we will always use the bit width equal to 16 bits and only one track. The function that I will write now will save our PCM sound in the Wav container inside the stream, which will make the work more flexible. So, this is how it looks.

 public static void SaveWave(Stream stream, short[] data, int sampleRate) { BinaryWriter writer = new BinaryWriter(stream); short frameSize = (short)(16 / 8); //     (16    8). writer.Write(0x46464952); //  "RIFF". writer.Write(36 + data.Length * frameSize); //     . writer.Write(0x45564157); //  "WAVE". writer.Write(0x20746D66); //  "frm ". writer.Write(16); //   . writer.Write((short)1); //  1  PCM. writer.Write((short)1); //  . writer.Write(sampleRate); //  . writer.Write(sampleRate * frameSize); //  (    ). writer.Write(frameSize); //    . writer.Write((short)16); // . writer.Write(0x61746164); //  "DATA". writer.Write(data.Length * frameSize); //    . for (int index = 0; index < data.Length; index++) { //      . foreach (byte element in BitConverter.GetBytes(data[index])) { //       . stream.WriteByte(element); //     . } } } 

You see how simple everything is, and most importantly now we can hear our sound, let's generate 1 second of note for the first octave, its frequency is 440 Hz, with such a task, the Main function will look like this

 public static void Main(string[] args) { int sampleRate = 8000; //   . short[] data = new short[sampleRate]; //   16  . double frequency = Math.PI * 2 * 440.0 / sampleRate; //   . for (int index = 0; index < sampleRate; index++) { //  . data[index] = (short)(Sine(index, frequency) * short.MaxValue); //      32767  -32767. } Stream file = File.Create("test.wav"); //        . SaveWave(file, data, sampleRate); //     . file.Close(); //  . } 

We start the program and about a miracle! We have a test.wav by downloading it in the player, listening to the squeaking before reaching the catharsis and moving on. Let's look at our wave from all sides on the oscilloscope and spectrogram to make sure that we got exactly the result of which we achieved.



But in life the sounds do not sound endlessly, but subside, let's write a modifier that will silence our sound with time. He will need absolute values, so we will give him a coefficient, a current position, a frequency, a factor multiplier and a sampling frequency, and he will calculate the absolute values ​​himself, the coefficient should always be negative.
 public static double Length(double compressor, double frequency, double position, double length, int sampleRate){ return Math.Exp(((compressor / sampleRate) * frequency * sampleRate * (position / sampleRate)) / (length / sampleRate)); } 

The line that calculates the sound level also needs to be changed.

 data[index] = (short)(Sine(index, frequency) * Length(-0.0015, frequency, index, 1.0, sampleRate) * short.MaxValue); 

Now on the oscilloscope we will see a completely different picture.


Write music


Since we managed to play the la note of the fourth octave, no one bothers us to play different notes. Have you ever wondered how to find out the frequencies of notes? It turns out there is a beautiful formula 440 * 2 ^ (absolute index of the note / 12). If you look at any piano-like instrument, then remember that it has blocks of 7 white keys and 5 black, blocks are octaves, white keys are basic notes (up, re, mi, fa, sol, la, si) and black their halftones, that is, only 12 sounds in an octave, this is called a uniformly tempered scale.
Let's look at the graph of this function.

But we will write down the notes in scientific notation, therefore we will slightly change the formula by lowering it by 4 octaves and we will write it down in our native form.

 private static double GetNote(int key, int octave) { return 27.5 * Math.Pow(2, (key + octave * 12.0) / 12.0); } 

Now that we have compiled the basic functionality and debugged its work, let's think over the architecture of the future synthesizer.
The synthesizer will be a certain set of elements of the elements that will synthesize sound and overlay it on an empty data array in the right place; this array and elements of the elements will be contained in the track object. The classes that describe them will be contained in the Synthesizer namespace, let's describe the Element and Track classes.

 public class Element { int length; int start; double frequency; double compressor; public Element(double frequency, double compressor, double start, double length, int sampleRate) { this.frequency = Math.PI * 2 * frequency / sampleRate ; this.start = (int)(start * sampleRate); this.length = (int)(length * sampleRate); this.compressor = compressor / sampleRate; } public void Get(ref short[] data, int sampleRate) { double result; int position; for (int index = start; index < start + length * 2; index++) { position = index - start; result = 0.5 * Sine(position, frequency) ; result += 0.4 * Sine(position, frequency / 4); result += 0.2 * Sine(position, frequency / 2); result *= Length(compressor, frequency, position, length, sampleRate) * short.MaxValue * 0.25; result += data[index]; if (result > short.MaxValue) result = short.MaxValue; if (result < -short.MaxValue) result = -short.MaxValue; data[index] = (short)(result); } } private static double Length(double compressor, double frequency, double position, double length, int sampleRate){ return Math.Exp((compressor * frequency * sampleRate * (position / sampleRate)) / (length / sampleRate)); } private static double Sine(int index, double frequency) { return Math.Sin(frequency * index); } } 

 public class Track { private int sampleRate; private List<Element> elements = new List<Element>(); private short[] data; private int length; private static double GetNote(int key, int octave) { return 27.5 * Math.Pow(2, (key + octave * 12.0) / 12.0); } public Track(int sampleRate) { this.sampleRate = sampleRate; } public void Add(double frequency, double compressor, double start, double length) { if (this.length < (start+ length * 2 + 1) * sampleRate) this.length = (int)(start + length * 2 +1) * sampleRate; elements.Add(new Element(frequency, compressor, start, length, sampleRate)); } public void Synthesize() { data = new short[length]; foreach (var element in elements) { element.Get(ref data, sampleRate); } } } 

Now we come to the last function that will read the line with notes and generate our melody
To do this, we will create a Dictionary which will associate the names of the notes with the indices, and will also contain the control keys / indices.
The function itself will break the string into words and further process each word separately, dividing it into two parts - left and right, the right part always consists of one character (digit) which is written into the variable octave as a number, and the length of the first part is the word length - 1 (that is, the word minus the right part) and then serves as the key to our dictionary that returns the index of the note, after we have disassembled the word we decide what to do, if the index is managing, then we will execute the function corresponding to the index, and if not, this means that we have the index of the note and we will add to our track a new sound of the length and frequency of our note we need.

 public void Music (string melody, double temp = 60.0) { string[] words = melody.Split(' '); foreach (string word in words) { int note = notes[word.Substring(0, word.Length - 1)]; int octave = Convert.ToInt32(word.Substring(word.Length - 1, 1)); if (note > 2){ switch (note) { case 3: dtime = Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp); break; case 4: length += (int)(Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp)); position += Math.Pow(0.5, octave + 1) * 8 * (60.0 / temp); break; } } else { Add(GetNote(note, octave), -0.51, position, dtime); position += dtime; } } } 


From this point on, the melody can be written as L4 B6 S4 D7 B6 F#6 S4 B6 F#6 Where L is a command that specifies the length of a note, and S creates a pause, the remaining characters are notes. Actually, the writing of the software synthesizer is completed on this, and we can check the result by listening to the segment “Bach’s joke”

Binary file
Source

Source: https://habr.com/ru/post/178915/


All Articles