📜 ⬆️ ⬇️

We program AGC (auto volume control) on VB.Net

The article is intended for beginners audiophiles who want to understand how the automatic volume control works (aka AGC and AGC ). Immediately I warn you that it will not go about how to get sound from a microphone or set the recording level of a sound card. Input data will be taken from the file, and even raw. If after this there remains a desire to make a parody of a long-invented bicycle with our own hands and experience the pioneering delight of discovery, then let's go!

Theory

General principles


The task, in fact, is quite simple: measure the current volume level, calculate the required gain and multiply the current value of the input signal by it. The one that follows with a frequency of 44.1 kHz or the like. For convenience of calculations, we take the maximum possible signal value per unit. If we want to always amplify to the maximum amplitude, then the calculation of the gain reduces to k = 1 / v , where v is the current volume level, and k is the gain. This is clear, but how to calculate v ? Let's see how the finished AGC does it. Here is its block diagram:


')
The first three blocks are involved in calculating the current volume level.
Lyrical digression
Some blocks are drawn as “black boxes”, because the device of their hardware analogs is unknown to me. For example, a diode bridge as a rectifier is not suitable, if only because it does not allow connecting the ground of the input directly to the ground of the circuit. But for us it is not important, because our AGC is software. We continue.
Valid values ​​of v we have from 0 to 1, right? Not. We will divide it into it, but we cannot divide it by 0! Therefore, it is necessary to introduce some kind of maximum gain factor kmax , for example, 100, which will act in minutes of silence. That is, the admissible values ​​of v we will have from 1 / kmax to 1. From 0.01 to 1, that is.

Now we recall that the input values ​​range from -1 to 1. We do not need negative values ​​for v , therefore we turn them into positive values ​​using a rectifier. If the output of the rectifier is a value less than 0.01, then fix it to 0.01. This deals with the limiter.

Now the fun part. The input signal varies over a wide frequency range. After the rectifier and the limiter, this range has expanded even more. And we need to AGC worked smoothly, allowing sharp jumps k only in moments when the input suddenly turned out to be a loud sound. In other cases, we do not need high oscillation frequencies k . We will get rid of them. "What? Zaryrn in the ranks of Fourier? Oh, yooooo! ”- the young reader is indignant. Do not panic! A simpler way is suitable for our purpose. If our AGC were hardware, we would use a smoothing capacitor. A mathematical embodiment of a capacitor is an exponential decay. Here is how it works. Denote the value at the output of the limiter by the letter u . We also need the previous value of v . If u> v , then we jump: v = u ; otherwise, quietly pull v to u : v = v - b * (v - u) . That is exactly what b determines and how quietly we pull up. It is only slightly greater than zero, so with each step we will reduce v by a tiny amount. You can calculate b before the AGC begins to work like this: b = 1 - 10 ^ (-1 / (sr * rt)) , where sr is the sampling rate of the input signal (44100 Hz or the like), rt is the approximate response time (adjustable parameter, default 10 seconds). In other words, if after a loud sound there is rt seconds of silence, then v during this time will pull up to u so that the difference between them will decrease by 10 times. So we got an asymmetric low pass filter (AFNCH). Asymmetric because it responds instantly to an increase in u , and responds to a decrease in a slow encroachment. A reaction time of 10 seconds means that the filter suppresses frequencies above 0.1 Hz and skips the rest. This, of course, is a very rough statement; in fact, there is no clear boundary for filtering. Nevertheless, the frequency of 0.1 Hz is far beyond the limit of the audible range, that is, the rectifier clicks almost will not penetrate the filter and annoy the standard listener. But only when there are no jumps.

Clipping


Now let's take a closer look at what happens during jumps. Usually they last several samples in a row. All this time, the asymmetric “valve” of the filter is open, and the gain is dramatically adjusted to the unusual volume. As a result, the values ​​of these several samples abut the "ceiling" and distort the shape of the sound wave. In English, this is called clipping, and the ear is perceived as a hoarse. Here’s what it looks like in a sound editor:



Anticlipping


Can we somehow deal with clipping? Yes. And it will help us in this that our AGC will work not in real time, but on previously recorded data. Having found a jump, we will easily go back in time and correct the gain for several previous samples. How far should you go back? Before the first transition of the input signal value through 0. In the picture this moment is shown by a white arrow. Why is he the most suitable? Yes, because the amplifier will multiply 0 by k , the product does not change from changing the places of the multipliers, and multiplying by 0 gives 0. That is, a sharp drop in k will smooth out naturally, and the listener will feel that our AGC was peering into the future (although in fact it changed the past) and at the time of going through 0, I knew in advance that a jump would follow. As it looks in the sound editor - we will see by launching the program. It's time to move on to practice!

Practice

We will build under Windows on VB.Net, so the first thing we need is Visual Studio. I used Visual Basic 2010 Express. Who likes other development tools, those who know the theory, easily remake the program to your liking.

Data format


The second thing we need is input. Raw, in the format of 16 bit PCM . A file of this format is a sequence of unsigned 16-bit signed integers. Numbers are written in the style of Intel: first the low byte, followed by the most significant one (and the order of the bits in the bytes themselves is not inverted). Each such number stores the value of the input signal. 32767 corresponds to 1, -32767 is -1, well, 0 it is 0. And -32768? Let it be -1 too - small error. Here is one way to get this file:
  1. Create a Windows PCM wav file in the sound editor, 16 bit per sample, mono , uncompressed. You also need to disable saving any additional information that is not directly related to the sound (if our sound editor has such an option).
  2. Cut the first 0x2C bytes from the file. This is a headline. Make sure that there are an even number of bytes, we have 2 bytes per sample.
  3. Change the file extension to pcm.

Or you can wipe the header with zeros - the AGC will think it is silence. Or slightly complicate the program so that it stupidly copied the first 0x2C bytes from the input file to the output. Then it will be possible to feed wav to it itself, without converting it into pcm. But we will leave this for a bright future, and now we have the following input data:



This is a human speech, in which one word is specifically marked by a loud act of censorship. This word is “X” and not “Habr” at all.

Buffering


And the third thing we need is some way to store a small piece of data for anticlipping. One second is enough with a margin, because in most real audio signals the transition through 0 occurs many times per second. For such purposes there is a ready-made class CircleArchive. Its copies work on the principle of a tape recorder with a ring tape: you can shove as much data as you like at the input, and when overflowed, the old data is overwritten by new ones. Here is the source:
CircleArchive.vb
Public Class CircleArchive Private InternalCapacity As Integer Private InternalArray() As Object Private InternalLength As Integer Private InternalStart As Integer Public Sub New(ByVal setCap As UShort) If (setCap = 0) Then InternalCapacity = UShort.MaxValue + 1 Else InternalCapacity = setCap End If InternalStart = 0 InternalLength = 0 InternalArray = New Object(InternalCapacity - 1) {} 'need to specify maxindex, not size as the parameter End Sub Public Sub AddObject(ByVal ObjectToAdd As Object) Dim NewIndex As Integer If IsFull Then 'overwrite the oldest InternalArray(InternalStart) = ObjectToAdd InternalStart = (InternalStart + 1) Mod InternalCapacity Else NewIndex = (InternalStart + InternalLength) Mod InternalCapacity InternalArray(NewIndex) = ObjectToAdd InternalLength += 1 End If End Sub Public Function GetObjectFIFO(ByVal Index As Integer) As Object Dim r As Object = Nothing Dim TrueIndex As Integer If ((Index >= 0) AndAlso (Index < InternalLength)) Then TrueIndex = (InternalStart + Index) Mod InternalCapacity r = InternalArray(TrueIndex) ElseIf (Index < 0) Then Throw New IndexOutOfRangeException("got negative value: " & Index.ToString) Else Throw New IndexOutOfRangeException("got " & Index.ToString & " when " & InternalLength.ToString & " item(s) stored") End If Return r End Function Public Function GetObject(ByVal Index As Integer) As Object 'just an alias for GetObjectFIFO Return GetObjectFIFO(Index) End Function Public Function GetObjectLIFO(ByVal Index As Integer) As Object Dim r As Object = Nothing Dim TrueIndex As Integer If ((Index >= 0) AndAlso (Index < InternalLength)) Then TrueIndex = InternalLength - 1 - Index 'invert TrueIndex = (InternalStart + TrueIndex) Mod InternalCapacity r = InternalArray(TrueIndex) ElseIf (Index < 0) Then Throw New IndexOutOfRangeException("got negative value: " & Index.ToString) Else Throw New IndexOutOfRangeException("got " & Index.ToString & " when " & InternalLength.ToString & " item(s) stored") End If Return r End Function Public Sub Clear() Dim i As Integer Dim TrueIndex As Integer For i = 0 To (InternalLength - 1) 'nullify existing items TrueIndex = (InternalStart + i) Mod InternalCapacity InternalArray(TrueIndex) = Nothing Next InternalLength = 0 End Sub 'Public Sub QuickClear() ' InternalLength = 0 'End Sub Public ReadOnly Property Capacity As Integer Get Return InternalCapacity End Get End Property Public ReadOnly Property Length As Integer Get Return InternalLength End Get End Property Public ReadOnly Property IsFull As Boolean Get Return (InternalLength = InternalCapacity) End Get End Property 'additional features Public Sub RemoveObjects(ByVal Index As Integer, ByVal Count As Integer) Dim r As Object = Nothing Dim TrueIndexSrc As Integer Dim TrueIndexDst As Integer Dim TrueCount As Integer Dim i As Integer If ((Index < 0) OrElse (Index >= InternalLength)) Then Exit Sub End If If (Count <= 0) Then Exit Sub End If If (Count < (InternalLength - Index)) Then TrueCount = Count Else TrueCount = InternalLength - Index End If If (TrueCount = InternalLength) Then 'need to delete all Clear() Else 'need to delete part of the items For i = Index To (Index + TrueCount - 1) TrueIndexSrc = (InternalStart + i) Mod InternalCapacity InternalArray(TrueIndexSrc) = Nothing Next 'nullification loop If (Index = 0) Then 'the beginning has been deleted InternalStart = (InternalStart + TrueCount) Mod InternalCapacity 'just move the start position ElseIf ((Index + TrueCount) < InternalLength) Then 'need array shift 'decide what direction it will be faster to shift If ((InternalLength - Index - TrueCount) <= Index) Then 'shift the end For i = (Index + TrueCount) To (InternalLength - 1) TrueIndexSrc = (InternalStart + i) Mod InternalCapacity TrueIndexDst = (InternalStart + i - TrueCount) Mod InternalCapacity InternalArray(TrueIndexDst) = InternalArray(TrueIndexSrc) InternalArray(TrueIndexSrc) = Nothing Next Else 'shift the beginning i = Index - 1 While (i >= 0) TrueIndexSrc = (InternalStart + i) Mod InternalCapacity TrueIndexDst = (InternalStart + i + TrueCount) Mod InternalCapacity InternalArray(TrueIndexDst) = InternalArray(TrueIndexSrc) InternalArray(TrueIndexSrc) = Nothing i -= 1 End While InternalStart = (InternalStart + TrueCount) Mod InternalCapacity 'move the start position End If 'array shift direction switch End If 'the third case is the end has been deleted: we don't need neither start movement nor array shift InternalLength -= TrueCount End If '(not) TrueCount = InternalLength End Sub 'RemoveObjects Public Sub RemoveFirst(ByVal Count As Integer) RemoveObjects(0, Count) End Sub Public Sub RemoveLast(ByVal Count As Integer) RemoveObjects((InternalLength - Count), Count) End Sub Public Sub InsertObject(ByVal ObjectToInsert As Object, ByVal InsBefore As Integer) Dim TrueIndexSrc As Integer Dim TrueIndexDst As Integer Dim i As Integer Dim FirstElementBuf As Object If ((InsBefore >= 0) AndAlso (InsBefore < InternalLength)) Then If (InsBefore = 0) Then If (Not IsFull) Then 'no need array shift, just move the start position 1 step backward InternalStart = (InternalStart + InternalCapacity - 1) Mod InternalCapacity InternalArray(InternalStart) = ObjectToInsert 'and increase length InternalLength += 1 End If 'Not IsFull Else 'need array shift 'decide what direction it will be faster to shift If (InsBefore > (InternalLength \ 2)) Then 'shift the end i = InternalLength - 1 While (i >= InsBefore) TrueIndexSrc = (InternalStart + i) Mod InternalCapacity TrueIndexDst = (InternalStart + i + 1) Mod InternalCapacity InternalArray(TrueIndexDst) = InternalArray(TrueIndexSrc) i -= 1 End While TrueIndexDst = (InternalStart + InsBefore) Mod InternalCapacity InternalArray(TrueIndexDst) = ObjectToInsert If IsFull Then 'the oldest was overwritten, need to move the start position 1 step forward InternalStart = (InternalStart + 1) Mod InternalCapacity Else InternalLength += 1 End If '(not) IsFull Else 'shift the beginning FirstElementBuf = InternalArray(InternalStart) For i = 1 To (InsBefore - 1) TrueIndexSrc = (InternalStart + i) Mod InternalCapacity TrueIndexDst = (InternalStart + i - 1) Mod InternalCapacity InternalArray(TrueIndexDst) = InternalArray(TrueIndexSrc) Next TrueIndexDst = (InternalStart + InsBefore - 1) Mod InternalCapacity InternalArray(TrueIndexDst) = ObjectToInsert If (Not IsFull) Then 'move the start position InternalStart = (InternalStart + InternalCapacity - 1) Mod InternalCapacity InternalArray(InternalStart) = FirstElementBuf InternalLength += 1 End If 'Not IsFull End If 'array shift direction switch End If '(not) InsBefore = 0 ElseIf (InsBefore < 0) Then Throw New IndexOutOfRangeException("got negative value: " & InsBefore.ToString) Else Throw New IndexOutOfRangeException("got " & InsBefore.ToString & " when " & InternalLength.ToString & " item(s) stored") End If End Sub 'InsertObject Public Sub ReplaceObject(ByVal Index As Integer, ByVal NewObject As Object) Dim TrueIndex As Integer If ((Index >= 0) AndAlso (Index < InternalLength)) Then TrueIndex = (InternalStart + Index) Mod InternalCapacity InternalArray(TrueIndex) = NewObject ElseIf (Index < 0) Then Throw New IndexOutOfRangeException("got negative value: " & Index.ToString) Else Throw New IndexOutOfRangeException("got " & Index.ToString & " when " & InternalLength.ToString & " item(s) stored") End If End Sub 'ReplaceObject End Class 

To battle!


We start the Visual Studio, in a hurry we create the Windows Form Application, we pile into the window a bunch of controls.

Even Target Volume had time to add. This is the amplitude to which we will increase. Valid positive values ​​are up to and including 1. Now we add the CircleArchive class to the project and selflessly write the code.
Form1.vb
 Public Class Form1 Private Structure AGCBufferElement Public InputPCMVal As Short ' ,    Public OutputPCMVal As Short ' ,      End Structure Private Sub ButtonAGC_Click(sender As System.Object, e As System.EventArgs) Handles ButtonAGC.Click Dim InputFileName As String = My.Application.Info.DirectoryPath & "\Input.pcm" Dim OutputFileName As String = My.Application.Info.DirectoryPath & "\Output.pcm" Dim InputFileStream As System.IO.FileStream = Nothing Dim OutputFileStream As System.IO.FileStream = Nothing Dim NSamples As Long '    Dim SampleIndex As Long Dim OneSecBufIndex As Integer '       anticlipping Dim kmax As Double = Decimal.ToDouble(NumericUpDownMaxGain.Value) Dim TargetVolume As Double = Decimal.ToDouble(NumericUpDownTargetVol.Value) '     Dim vmin As Double '  Dim AGCLeap As Boolean '  Dim k As Double '  Dim b As Double '  Dim CurrBuf As AGCBufferElement '  PCM Dim PrevBuf As AGCBufferElement Dim u As Double '  Dim v As Double ' ,        Dim OneSecBuf As CircleArchive '       anticlipping Dim NegHalfwave As Boolean '     0 '  Try If (My.Computer.FileSystem.FileExists(InputFileName)) Then InputFileStream = New System.IO.FileStream(InputFileName, IO.FileMode.Open) OutputFileStream = New System.IO.FileStream(OutputFileName, IO.FileMode.Create) End If Catch ex As Exception End Try If ((InputFileStream IsNot Nothing) AndAlso (OutputFileStream IsNot Nothing)) Then ' vmin = TargetVolume / kmax b = 1.0 - Math.Pow(10.0, (-1.0 / Decimal.ToDouble(Decimal.Multiply(NumericUpDownSampleRate.Value, NumericUpDownFalloffTime.Value)))) v = vmin OneSecBuf = New CircleArchive(CUShort(NumericUpDownSampleRate.Value)) InputFileStream.Position = 0 NSamples = InputFileStream.Length \ 2 '2 bytes per sample '! For SampleIndex = 0 To (NSamples - 1) '  PCM   CurrBuf.InputPCMVal = CShort(InputFileStream.ReadByte) 'LSB first (Intel manner) CurrBuf.InputPCMVal = CurrBuf.InputPCMVal Or (CShort(InputFileStream.ReadByte) << 8) 'MSB last (Intel manner) If (CurrBuf.InputPCMVal = Short.MinValue) Then CurrBuf.InputPCMVal += 1 '     -32767 .. 32767 End If '  Double    If (CurrBuf.InputPCMVal < 0) Then u = -CurrBuf.InputPCMVal / Short.MaxValue Else u = CurrBuf.InputPCMVal / Short.MaxValue End If '   ' If (u < vmin) Then u = vmin End If '   '  AGCLeap = (u > v) If AGCLeap Then v = u End If '   ,      k = TargetVolume / v '   '   If (AGCLeap AndAlso CheckBoxAnticlipping.Checked) Then ' anticlipping:            0 NegHalfwave = (CurrBuf.InputPCMVal < 0) '     ? OneSecBufIndex = OneSecBuf.Length - 1 While (OneSecBufIndex >= 0) PrevBuf = CType(OneSecBuf.GetObjectFIFO(OneSecBufIndex), AGCBufferElement) '   0 If (PrevBuf.InputPCMVal = 0) Then Exit While ElseIf (NegHalfwave Xor (PrevBuf.InputPCMVal < 0)) Then Exit While End If '     ,    0   PrevBuf.OutputPCMVal = PrevBuf.InputPCMVal * k '    PCM        (   ,  k  ) OneSecBuf.ReplaceObject(OneSecBufIndex, PrevBuf) '  OneSecBufIndex -= 1 ' ,  ,    End While '   OneSecBuf End If ' anticlipping CurrBuf.OutputPCMVal = CurrBuf.InputPCMVal * k '     If OneSecBuf.IsFull Then '             ,    PrevBuf = CType(OneSecBuf.GetObjectFIFO(0), AGCBufferElement) Try '   OutputFileStream.WriteByte(CByte(PrevBuf.OutputPCMVal And Byte.MaxValue)) 'LSB first (Intel manner) OutputFileStream.WriteByte(CByte((PrevBuf.OutputPCMVal >> 8) And Byte.MaxValue)) 'MSB last (Intel manner) Catch ex As Exception End Try End If 'OneSecBuf.IsFull OneSecBuf.AddObject(CurrBuf) '     OneSecBuf ' ,      If (Not AGCLeap) Then v = v - b * (v - u) '  v      End If Next '     ' OneSecBuf For OneSecBufIndex = 0 To (OneSecBuf.Length - 1) PrevBuf = CType(OneSecBuf.GetObjectFIFO(OneSecBufIndex), AGCBufferElement) Try OutputFileStream.WriteByte(CByte(PrevBuf.OutputPCMVal And Byte.MaxValue)) 'LSB first (Intel manner) OutputFileStream.WriteByte(CByte((PrevBuf.OutputPCMVal >> 8) And Byte.MaxValue)) 'MSB last (Intel manner) Catch ex As Exception End Try Next '   OneSecBuf End If '     If (OutputFileStream IsNot Nothing) Then OutputFileStream.Close() End If If (InputFileStream IsNot Nothing) Then InputFileStream.Close() End If MsgBox("The end.") End Sub End Class 

As can be seen from the code, the program will take the file Input.pcm, lying next to its exe (put it there if you have not had time yet) and create Output.pcm there with the result of the work. We start. We set Falloff Time 10 seconds (this is the reaction time), Max Gain 20, Target Volume 0.95 (to see anticlipping in all its glory). Do not forget about the sampling frequency, because it is not stored in the file with raw data. Turn on Anticlipping and press the button. It turned out Output.pcm? Of course yes! Convert it back to wav, returning the title to the place, listen. We load in the sound editor and we see:



You can see how the AGC gradually comes to life after a deafening squeak, carefully returning the gain to the previous level. In this process, the reaction time we set is of primary importance. Now look at the fragment, which was recently an example of clipping.



This is exactly the place where the censor begins. By the way, finally a little more about anticlipping ...

Instead of conclusion

Our anticlipping algorithm is far from optimal. The usual leap, I remind you, lasts several samples in a row. Processing each of them, we again and again go back in time and recalculate a significant amount of data, wasting processor time. Instead, you should only go back when the jump is over, before proceeding to a decay. And to guess that for this we need to add to the code, we will not be difficult.

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


All Articles