Статья предназначена для начинающих аудиофилов, желающих разобраться как работает автоматический регулятор громкости (он же AGC и АРУ). Сразу предупреждаю, что речь не пойдёт о том, как получить звук с микрофона или выставить уровень записи звуковой карты. Входные данные будем брать из файла, да ещё и в сыром виде. Если после этого осталось желание своими руками сделать пародию на давно изобретённый велосипед и испытать пионерский восторг открытия, то поехали!
Теория
Общие принципы
Задача, по сути, весьма проста: измерить текущий уровень громкости, вычислить требуемый коэффициент усиления и умножить на него текущее значение входного сигнала. То самое, которое следует с частотой 44.1 кГц или тому подобной. Для удобства вычислений максимально возможное значение сигнала примем за единицу. Если хотим всегда усиливать до максимальной амплитуды, то вычисление коэффициента усиления сводится к k = 1/v, где v – текущий уровень громкости, а k это и есть коэффициент усиления. Это ясно, а как вычислить v? Посмотрим, как это делает готовый AGC. Вот его блок-схема:
Вычислением действующего в данный момент уровня громкости занимаются первые три блока.
Допустимые значения v у нас от 0 до 1, так? Нет. Мы же будем на него делить, а на 0 делить нельзя! Поэтому надо ввести какой-то максимальный коэффициент усиления kmax, например 100, который будет действовать в минуты тишины. То есть допустимые значения v у нас будут от 1/kmax до 1. От 0.01 до 1 то есть.
Теперь вспоминаем, что входные значения колеблются в пределах от -1 до 1. Отрицательные значения нам для v не нужны, поэтому превращаем их в положительные с помощью выпрямителя. Если на выходе выпрямителя получается значение меньше 0.01, то исправляем его на 0.01. Этим занимается ограничитель.
Теперь самое интересное. Входной сигнал колеблется в широком диапазоне частот. После выпрямителя и ограничителя этот диапазон расширился ещё больше. А нам надо, чтоб AGC работал плавно, допуская резкие скачки k только в моменты, когда на входе неожиданно оказался громкий звук. В остальных случаях ни к чему нам высокие частоты колебаний k. Будем от них избавляться. «Что? Занырнём в ряды Фурье? Ой ёооо!» — негодует юный читатель. Без паники! Для нашей цели годится и более простой способ. Если бы наш AGC был аппаратным, то мы бы применили сглаживающий конденсатор. А математическое воплощение конденсатора это экспоненциальное затухание. Вот как это работает. Обозначим значение на выходе ограничителя буквой u. Также нам понадобится предыдущее значение v. Если u > v, то делаем скачок: v = u; в противном случае тихооонечко подтягиваем v к u: v = v — b * (v — u). Вот это b как раз и определяет, насколько тихонечко мы подтягиваем. Оно лишь немного больше нуля, поэтому с каждым шагом мы будем уменьшать v на мизерную величину. Вычислить b можно ещё до начала работы AGC вот так: b = 1 — 10 ^ (-1 / (sr * rt)), где sr – частота дискретизации входного сигнала (44100 Гц или тому подобная), rt – приблизительное время реакции (настраиваемый параметр, default 10 секунд). Иными словами, если после громкого звука будет rt секунд тишины, то v за это время так подтянется к u, что разница между ними уменьшится в 10 раз. Так у нас получился асимметричный фильтр низких частот (АФНЧ). Асимметричный потому, что на повышение u он реагирует мгновенно, а на понижение откликается медленным поползновением. Время реакции 10 секунд значит, что фильтр подавляет частоты выше 0.1 Гц и пропускает остальные. Это, конечно, очень грубое утверждение, на самом деле чёткой границы фильтрации нет. Тем не менее, частота 0.1 Гц находится далеко за пределом слышимого диапазона, то есть щелчки выпрямителя почти не будут проникать сквозь фильтр и раздражать стандартного слушателя. Но только когда нет скачков.
Clipping
Теперь рассмотрим подробнее, что происходит во время скачков. Обычно они длятся несколько сэмплов подряд. Всё это время асимметричный «клапан» фильтра открыт, и коэффициент усиления резко подстраивается под непривычную громкость. В результате значения этих нескольких сэмплов упираются в «потолок» и искажается форма звуковой волны. По-английски это называется clipping, а на слух воспринимается как хрип. Вот как это выглядит в звуковом редакторе:
Anticlipping
Можем ли мы как-то бороться с clipping'ом? Да. И поможет нам в этом то, что наш AGC будет работать не в реальном времени, а над заранее записанными данными. Обнаружив скачок, мы запросто вернёмся назад во времени и исправим коэффициент усиления для нескольких предыдущих сэмплов. Как далеко надо возвращаться назад? До первого перехода значения входного сигнала через 0. На картинке этот момент показан белой стрелкой. Почему он самый подходящий? Да потому, что усилитель будет умножать 0 на k, от перемены мест множителей произведение не меняется, а умножение на 0 даёт 0. То есть резкое падение k сгладится естественным образом, а слушателю покажется, что наш AGC подглядывал в будущее (хотя фактически он изменял прошлое) и в момент перехода через 0 заранее знал, что дальше последует скачок. Как это выглядит в звуковом редакторе – увидим, запустив программу. Пора переходить к практике!
Практика
Строить будем под Windows на VB.Net, поэтому первое, что нам понадобится, это Visual Studio. Я использовал Visual Basic 2010 Express. Кому больше нравятся другие средства разработки, те, зная теорию, легко переделают программу по своему вкусу.
Формат данных
Второе, что нам нужно, это входные данные. Сырые, в формате 16 bit PCM. Файл такого формата представляет собой последовательность ничем не разделённых 16-битных целых чисел со знаком. Числа записаны в стиле Intel: сначала младший байт, за ним старший (при этом порядок битов в самих байтах не перевёрнут). Каждое такое число хранит значение входного сигнала. 32767 соответствует 1, -32767 это -1, ну а 0 он и есть 0. А -32768? Пусть тоже будет -1 – невелика погрешность. Вот один из способов получить такой файл:
- Создать в звуковом редакторе файл в формате Windows PCM wav, 16 bit per sample, моно, без сжатия. Ещё надо отключить сохранение всякой дополнительной информации, не имеющей прямого отношения к звуку (если у нашего звукового редактора есть такая опция).
- Отрезать от файла первые 0x2C байта. Это заголовок. Убедиться, что осталось чётное число байтов, у нас ведь 2 байта на сэмпл.
- Изменить расширение файла на pcm.
Или можно затереть заголовок нулями – AGC будет думать, что это тишина. Или слегка усложнить программу, чтоб она тупо копировала первые 0x2C байта из входного файла в выходной. Тогда уже можно будет скармливать ей сам wav, не преобразуя в pcm. Но это мы оставим на светлое будущее, а сейчас у нас входные данные вот такие:
Это человеческая речь, в которой одно слово специально запикано громким актом цензуры. Это слово «икс», а вовсе не «хабр».
Буферизация
И третье, что нам нужно, это какой-нибудь способ хранения небольшого фрагмента данных для anticlipping'а. Одной секунды хватит с запасом, потому что в большинстве реальных аудиосигналов переход через 0 происходит много раз в секунду. Для таких целей есть готовый класс CircleArchive. Его экземпляры работают по принципу магнитофона с кольцевой лентой: на вход можно пихать сколько угодно данных, а при переполнении старые данные затираются новыми. Вот исходник:
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
В бой!
Запускаем Visual Studio, впопыхах создаём Windows Form Application, наваливаем в окошко кучу элементов управления.
Даже Target Volume успели добавить. Это амплитуда, до которой будем усиливать. Допустимы положительные значения до 1 включительно. Теперь добавляем в проект класс CircleArchive и самозабвенно пишем код.
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
Как видно из кода, программа возьмёт файл Input.pcm, лежащий рядом с её exe (кладём его туда если ещё не успели) и создаст там же Output.pcm с результатом работы. Запускаем. Выставляем Falloff Time 10 секунд (это время реакции), Max Gain 20, Target Volume 0.95 (чтоб увидеть во всей красе anticlipping). Не забываем про частоту дискретизации, потому что в файле с сырыми данными она не хранится. Включаем Anticlipping и жмём кнопку. Получился Output.pcm? Конечно да! Преобразовываем его обратно в wav, возвращая на место заголовок, слушаем. Загружаем в звуковой редактор и видим:
Видно как AGC постепенно приходит в себя после оглушительного писка, осторожно возвращая коэффициент усиления к прежнему уровню. В этом процессе главное значение имеет установленное нами время реакции. Теперь посмотрим на фрагмент, который ещё недавно был примером clipping'а.
Это как раз то место, где начинается писк цензуры. Кстати, напоследок ещё немного об anticlipping'е…
Вместо заключения
Наш алгоритм anticlipping'а далеко не оптимален. Обычный скачок, напомню, длится несколько сэмплов подряд. Обрабатывая каждый из них, мы вновь и вновь возвращаемся назад во времени и пересчитываем значительный объём данных, впустую тратя процессорное время. Вместо этого надо возвращаться назад только когда скачок окончен, перед тем, как переходить к затуханию. А уж догадаться, что для этого надо добавить в код, нам не составит труда.
Автор: mporshnev