Генерация звуковых волн с помощью волнового осциллятора на C#
Опубликовано 03 мая 2010 г. 13:24 | Coding4Fun
Долгое время меня напрочь сбивали с толку принципы работы со звуком на компьютерах. В каком же виде хранится звук? Как он воспроизводится? В соответствии с традиционным стилем Coding4Fun мы освоим эти принципы на практике — созданием приложения «волновой осциллятор».
Автор: Дэн Уотерс (Dan Waters)
Academic Evangelist, Microsoft
Исходный код: https://code.msdn.microsoft.com/wpf3osc
Сложность: средняя
Необходимое время: 12 часов
Затраты: бесплатно
ПО: Visual Basic или Visual C# Express, DirectX SDK, Expression Blend (необязателен, нужен в приложении только для создания UI на основе WPF)
Дополнительные материалы
Базовые сведения, необходимые для понимания этой статьи, я изложил в серии статей:
Часть 1: как представляются аудиоданные (EN)
Часть 2: срываем завесу тайны с формата WAV (EN)
Часть 3: синтез простого звука в формате WAV с помощью C# (EN)
Часть 4: алгоритмы создания различных звуковых волн на C# (EN)
Что такое осциллятор?
Осциллятор — устройство или приложение, которое генерирует волновые колебания. В электротехнике это устройство, которое дает на выходе электрический ток с переменным напряжением. Если вы построите график зависимости напряжения от времени, то получите волновые колебания определенной формы, например синусоидальные, прямоугольные, треугольные или пилообразные.
Осциллятор — самый примитивный вид синтезатора. Аналоговые синтезаторы генерируют звуковые волны с помощью электрических контуров, а цифровые — делают то же самое, но программным способом.
Вы можете создать весьма прилично звучащий инструмент, комбинируя выходные сигналы нескольких осцилляторов. Например, если у вас есть три осциллятора, выдающих колебания с частотой 440 Гц, но каждый из них дает волну своей формы (пилообразную, прямоугольную, синусоидальную), то вы получите очень интересный, многослойный звук.
Но прежде чем углубляться в эту тематику, давайте кратко рассмотрим физику звука.
Физиказвука
Мы слышим звук, когда на наши перепонки в ушах воздействует переменное давление воздуха. Если вы хлопнете в ладоши в пустом помещении, колебания давления разойдутся по всему помещению и вы услышите их. Изменения в давлении постоянно воспринимаются ухом.
С цифровой точки зрения, «давление» определяется скалярным значением, которое называют амплитудой (amplitude) . Амплитуда (громкость) волны измеряется в тысячах колебаний в секунду (44 100 раз в секунду для звука с качеством аудио компакт-диска). Каждое значение измерения давления (амплитуды) называют выборкой (sample) — компакт-диски записываются с частотой дискретизации 44 100 выборок в секунду, каждая из которых является средним значением между минимальной и максимальной амплитудой при данной разрядности.
Только подумайте об этой цифре: 44 100 выборок в секунду. Это колоссальный объем информации, распознаваемый ухом. Вот почему мы слышим так много в какой-либо песне, где столько всего намешано, — особенно в стерео, когда на каждое ухо приходится по 44 100 выборок в секунду.
Оказывается, существует ужасно сложная математическая теорема, главный смысл которой в том, что 44 100 выборок в секунду достаточно точно представляют звук высотой до 22 кГц. Человеческое ухо в состоянии слышать только до 20 кГц, поэтому частота дискретизации 44,1 кГц даже немного избыточна.
Весь этот раздел подробно раскрыт в моем блоге в статье «Часть 1: как представляются аудиоданные» (EN).
Терминология
Итак, я дал вам довольно поверхностный обзор восприятия звука и, возможно, некоторые намеки насчет того, как он должен быть представлен в компьютерах. Давайте пройдемся по всей этой новой терминологии (да и по некоторым еще более новым терминам), организовав ее в удобный маркированный список.
- Выборка (sample) — измеренный фрагмент звуковой волны за очень малый промежуток времени. 44 100 таких фрагментов подряд образуют один аудиоканал с качеством CD.
- Амплитуда (amplitude) — значение выборки. Максимальное и минимальное значения зависят от разрядности.
- Разрядность (bit depth) — количество битов, используемых для представления выборки: 16, 32 и т. д. Максимальная амплитуда равна (2^разрядность) / 2 – 1.
- Частота дискретизации (sample rate, sampling rate, bit rate) — число выборок в секунду. Стандартом для аудио качества CD является частота дискретизации, равная 44 100.
Как представляется звук
К этому моменту вы, вероятно, догадались, что секунда аудиоданных каким-то образом представляется массивом некоего типа целочисленных данных с размером в 44 100 элементов. И вы недалеки от истины. Однако, если вы хотите проигрывать аудиоданные со звуковой платы компьютера, эти данные должны быть дополнены целым букетом сведений о формате. По-видимому, самый простой в обращении формат — WAV.
Более подробную информацию по этой тематике см. в статье «Часть 2: срываем завесу тайны с формата WAV» (EN). Как формировать WAV-файл в старом и двоичном стиле, см. в статье «Часть 3: синтез простого звука в формате WAV с помощью C#» (EN).
Но мы пойдем по более простому пути и будем использовать DirectSound. DirectSound дает нам массу удобных классов для любых форматов, абстрагируя их и позволяя нам просто закачивать поток данных в DirectSound-объект и воспроизводить их. Для приложения-синтезатора лучше и не придумаешь!
Итак, начнем!
Создание приложения
Я немного освоил Blend, пока работал над этим приложением, потому что оно построено на WPF. Кнопки-картинки — это просто переключатели. Мне нужно было различать номера групп на каждый экземпляр пользовательского элемента управления в период выполнения (в конструкторе класса Oscillator).
Вообще-то я скверный дизайнер UI, и у меня получилось нечто, что по крайне мере позволяет работать с этим приложением. Но вы не стесняйтесь и изменяйте его так, чтобы он выглядел и работал лучше!
Разработка UI
В этом приложении есть один маленький секрет. Заявлено, что оно может генерировать три волны, но по правде, это определяется константой (равной 3), которую можно изменить. При желании вы могли бы генерировать и шесть волн. Как я сделал это? Каждый синтезатор, который вы видите, является экземпляром пользовательского элемента управления WPF под названием Oscillator.xaml:
В основное окно я поместил StackPanel с именем Oscs. В обработчике событий Window_Loaded основного окна я добавляю экземпляры пользовательского элемента управления следующим образом:
C#
// Добавляем три осциллятора
Oscillator tmp;
for (int i = 0; i < NUM_GENERATORS; i++)
{
tmp = new Oscillator();
Oscs.Children.Add(tmp);
mixer.Oscillators.Add(tmp);
}
VB
' Добавляем три осциллятора
Dim tmp As Oscillator
Dim i As Integer = 0
While i < NUM_GENERATORS
tmp = New Oscillator()
Oscs.Children.Add(tmp)
mixer.Oscillators.Add(tmp)
System.Math.Max(System.Threading.Interlocked.Increment(i),i - 1)
End While
Для нанесения значений сгенерированной волны используется длинный прямоугольный холст (canvas), поэтому при прослушивании волну можно визуализировать. Она масштабируется по оси X, что позволяет увидеть ее общую форму; без масштабирования это было бы невозможно, учитывая 44 100 выборок в секунду.
Ранее я отметил, что звуковой файл фактически является очень-очень длинным массивом 16- или 32-разрядных чисел с плавающей точкой в диапазоне от –1 до 1. Мы используем эти данные и для построения графика, но все подробности позже.
Теперь, когда у нас есть какой-никакой UI (обеспечивающий динамическое добавление осцилляторов), давайте рассмотрим, как же создается звук.
Создание звуков и микшер
Одно из многих классных качеств DirectSound заключается в том, что этот интерфейс умеет обертывать формат WAV, скрывая его от вас. Вы задаете параметры буферизации и формата, а потом посылаете ему порцию данных, и музыка играет. Магия.
Я предпочел чуть более модульную архитектуру для своего решения. Ни один осциллятор сам по себе ничего не воспроизводит — он использует свой UI для управления некоторыми значениями, такими как частота, амплитуда и тип волны. Эти значения связываются с открытыми свойствами. Компонент Oscillator не делает практически ничего, что можно было бы отнести к созданию аудиоданных.
Генерация аудиоданных обрабатывается собственным классом Mixer, который принимает набор Oscillators и, исходя из значений свойств объектов в наборе, формирует суммарный вывод всех генераторов. Это осуществляется усреднением выборок в каждом осцилляторе и их записью в новый массив данных.
Класс Mixer выглядит так:
Одна из рабочих лошадок класса Mixer — метод GenerateOscillatorSampleData. Он принимает Oscillator в качестве аргумента и предоставляет доступ к открытым свойствам в UI. Его алгоритм генерирует одну секунду данных выборок (длительность определяется членом bufferDurationSeconds) с учетом типа волны, выбранной в UI. И здесь в игру вступает математический аппарат. Просмотрите этот метод и различные блоки case в выражении switch, определяющие, какая волна будет генерироваться.
C#
public short[] GenerateOscillatorSampleData(Oscillator osc)
{
// Создаем циклический буфер на основе свойств переданного объекта.
// Заполняем буфер данными соответствующей волны с указанной частотой.
int numSamples = Convert.ToInt32(bufferDurationSeconds *
waveFormat.SamplesPerSecond);
short[] sampleData = new short[numSamples];
double frequency = osc.Frequency;
int amplitude = osc.Amplitude;
double angle = (Math.PI * 2 * frequency) /
(waveFormat.SamplesPerSecond * waveFormat.Channels);
switch (osc.WaveType)
{
case WaveType.Sine:
{
for (int i = 0; i < numSamples; i++)
// Генерируем синусоидальную волну в обоих каналах
sampleData[i] = Convert.ToInt16(amplitude *
Math.Sin(angle * i));
}
break;
case WaveType.Square:
{
for (int i = 0; i < numSamples; i++)
{
// Генерируем прямоугольную волну в обоих каналах
if (Math.Sin(angle * i) > 0)
sampleData[i] = Convert.ToInt16(amplitude);
else
sampleData[i] = Convert.ToInt16(-amplitude);
}
}
break;
case WaveType.Sawtooth:
{
int samplesPerPeriod = Convert.ToInt32(
waveFormat.SamplesPerSecond /
(frequency / waveFormat.Channels));
short sampleStep = Convert.ToInt16(
(amplitude * 2) / samplesPerPeriod);
short tempSample = 0;
int i = 0;
int totalSamplesWritten = 0;
while (totalSamplesWritten < numSamples)
{
tempSample = (short)-amplitude;
for (i = 0; i < samplesPerPeriod &&
totalSamplesWritten < numSamples; i++)
{
tempSample += sampleStep;
sampleData[totalSamplesWritten] = tempSample;
totalSamplesWritten++;
}
}
}
break;
case WaveType.Noise:
{
Random rnd = new Random();
for (int i = 0; i < numSamples; i++)
{
sampleData[i] = Convert.ToInt16(
rnd.Next(-amplitude, amplitude));
}
}
break;
}
return sampleData;
}
VB
Public Function GenerateOscillatorSampleData(ByVal osc As Oscillator) As Short()
' Создаем циклический буфер на основе свойств переданного объекта.
' Заполняем буфер данными соответствующей волны с указанной частотой.
Dim numSamples As Integer = Convert.ToInt32(
bufferDurationSeconds * waveFormat.SamplesPerSecond)
Dim sampleData As Short() = New Short(numSamples - 1) {}
Dim frequency As Double = osc.Frequency
Dim amplitude As Integer = osc.Amplitude
Dim angle As Double = (Math.PI * 2 * frequency) /
(waveFormat.SamplesPerSecond * waveFormat.Channels)
Select Case osc.WaveType
Case WaveType.Sine
If True Then
For i As Integer = 0 To numSamples - 1
' Генерируем синусоидальную волну в обоих каналах
sampleData(i) =
Convert.ToInt16(amplitude * Math.Sin(angle * i))
Next
End If
Exit Select
Case WaveType.Square
If True Then
For i As Integer = 0 To numSamples - 1
' Генерируем прямоугольную волну в обоих каналах
If Math.Sin(angle * i) > 0 Then
sampleData(i) = Convert.ToInt16(amplitude)
Else
sampleData(i) = Convert.ToInt16(-amplitude)
End If
Next
End If
Exit Select
Case WaveType.Sawtooth
If True Then
Dim samplesPerPeriod As Integer =
Convert.ToInt32(waveFormat.SamplesPerSecond /
(frequency / waveFormat.Channels))
Dim sampleStep As Short =
Convert.ToInt16((amplitude * 2) / samplesPerPeriod)
Dim tempSample As Short = 0
Dim i As Integer = 0
Dim totalSamplesWritten As Integer = 0
While totalSamplesWritten < numSamples
tempSample = CShort(-amplitude)
i = 0
While i < samplesPerPeriod AndAlso totalSamplesWritten < numSamples
tempSample += sampleStep
sampleData(totalSamplesWritten) = tempSample
totalSamplesWritten += 1
i += 1
End While
End While
End If
Exit Select
Case WaveType.Noise
If True Then
Dim rnd As New Random()
For i As Integer = 0 To numSamples - 1
sampleData(i) = Convert.ToInt16(
rnd.[Next](-amplitude, amplitude))
Next
End If
Exit Select
End Select
Return sampleData
End Function
Класс Mixer является сердцевиной приложения и ярким примером объектной ориентированности и сцепления (cohesion). Дайте ему три объекта (осциллятора), и он выдаст новый объект, который вы сможете использовать (массив данных выборок).
Теперь, когда у нас есть данные выборок, нам остается лишь воспроизвести их через DirectSound.
Воспроизведениезвукачерез DirectSound
Как я уже упоминал, DirectSound предоставляет оболочку формата WAV. Вы настраиваете свой буфер, указываете информацию о формате, а затем помещаете в него порцию данных в виде массива типа short (как известно, массивы trousers вызывают ошибки)( Игра слов: short (короткие целые), shorts — шорты, trousers — штаны: Прим.переводчика).
Первым делом мы инициализируем информацию о формате и буфер в обработчике событий Window_Loaded основной формы. Значения ниже на самом деле не являются произвольными; ссылка на соответствующее объяснение есть в разделе «Дополнительные материалы» (см. «Часть 2: срываем завесу тайны с формата WAV»). В этом коде также добавляются осцилляторы, как было показано в статье ранее.
C#
private void Window_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
WindowInteropHelper helper =
new WindowInteropHelper(Application.Current.MainWindow);
device.SetCooperativeLevel(helper.Handle, CooperativeLevel.Normal);
waveFormat = new Microsoft.DirectX.DirectSound.WaveFormat();
waveFormat.SamplesPerSecond = 44100;
waveFormat.Channels = 2;
waveFormat.FormatTag = WaveFormatTag.Pcm;
waveFormat.BitsPerSample = 16;
waveFormat.BlockAlign = 4;
waveFormat.AverageBytesPerSecond = 176400;
bufferDesc = new BufferDescription(waveFormat);
bufferDesc.DeferLocation = true;
bufferDesc.BufferBytes = Convert.ToInt32(
bufferDurationSeconds * waveFormat.AverageBytesPerSecond / waveFormat.Channels);
// Добавляем три осциллятора
Oscillator tmp;
for (int i = 0; i < NUM_GENERATORS; i++)
{
tmp = new Oscillator();
Oscs.Children.Add(tmp);
mixer.Oscillators.Add(tmp);
}
}
VB
Private Sub Window_Loaded(ByVal sender As Object, ByVal e As System.Windows.RoutedEventArgs)
Dim helper As New WindowInteropHelper(Application.Current.MainWindow)
device.SetCooperativeLevel(helper.Handle, CooperativeLevel.Normal)
waveFormat = New Microsoft.DirectX.DirectSound.WaveFormat()
waveFormat.SamplesPerSecond = 44100
waveFormat.Channels = 2
waveFormat.FormatTag = WaveFormatTag.Pcm
waveFormat.BitsPerSample = 16
waveFormat.BlockAlign = 4
waveFormat.AverageBytesPerSecond = 176400
bufferDesc = New BufferDescription(waveFormat)
bufferDesc.DeferLocation = True
bufferDesc.BufferBytes = Convert.ToInt32(
bufferDurationSeconds * waveFormat.AverageBytesPerSecond / waveFormat.Channels)
' Добавляем три осциллятора
Dim tmp As Oscillator
For i As Integer = 0 To NUM_GENERATORS - 1
tmp = New Oscillator()
Oscs.Children.Add(tmp)
mixer.Oscillators.Add(tmp)
Next
End Sub
Когда вы щелкаете кнопку Play, приложение принимает свой набор осцилляторов и передает значения UI-элементов в Mixer (который при каждом щелчке инициализируется ссылкой на окно основной формы, поэтому он может захватывать значения пользовательских элементов управления Oscillator).
Микшер возвращает массив типа short, который мы записываем в буфер DirectSound.
Вот код для обработчика события щелчка кнопки Play:
C#
private void btnPlay_Click(object sender, System.Windows.RoutedEventArgs e)
{
mixer.Initialize(Application.Current.MainWindow);
short[] sampleData = mixer.MixToStream();
buffer = new SecondaryBuffer(bufferDesc, device);
buffer.Write(0, sampleData, LockFlag.EntireBuffer);
buffer.Play(0, BufferPlayFlags.Default);
GraphWaveform(sampleData);
}
VB
Private Sub btnPlay_Click(sender As Object, e As System.Windows.RoutedEventArgs)
mixer.Initialize(Application.Current.MainWindow)
Dim sampleData As Short() = mixer.MixToStream()
buffer = New SecondaryBuffer(bufferDesc, device)
buffer.Write(0, sampleData, LockFlag.EntireBuffer)
buffer.Play(0, BufferPlayFlags.[Default])
GraphWaveform(sampleData)
End Sub
Рисование красивых графиков
Нам осталось лишь нарисовать на холсте график волновых колебаний. Эту задачу выполняет метод GraphWaveform, показанный ниже. Он может рисовать все, что угодно, если вы передаете ему массив short-значений (а не trousers). WPF-объект Polyline делает эту задачу весьма тривиальной.
C#
private void GraphWaveform(short[] data)
{
cvDrawingArea.Children.Clear();
double canvasHeight = cvDrawingArea.Height;
double canvasWidth = cvDrawingArea.Width;
int observablePoints = 1800;
double xScale = canvasWidth / observablePoints;
double yScale = (canvasHeight /
(double)(amplitude * 2)) * ((double)amplitude / MAX_AMPLITUDE);
Polyline graphLine = new Polyline();
graphLine.Stroke = Brushes.Black;
graphLine.StrokeThickness = 1;
for (int i = 0; i < observablePoints; i++)
{
graphLine.Points.Add(
new Point(i * xScale, (canvasHeight / 2) - (data[i] * yScale) ));
}
cvDrawingArea.Children.Add(graphLine);
}
VB
Private Sub GraphWaveform(ByVal data As Short())
cvDrawingArea.Children.Clear()
Dim canvasHeight As Double = cvDrawingArea.Height
Dim canvasWidth As Double = cvDrawingArea.Width
Dim observablePoints As Integer = 1800
Dim xScale As Double = canvasWidth / observablePoints
Dim yScale As Double = (canvasHeight / CDbl((amplitude * 2))) * (CDbl(amplitude) / MAX_AMPLITUDE)
Dim graphLine As New Polyline()
graphLine.Stroke = Brushes.Black
graphLine.StrokeThickness = 1
For i As Integer = 0 To observablePoints - 1
graphLine.Points.Add(
New Point(i * xScale, (canvasHeight / 2) - (data(i) * yScale)))
Next
cvDrawingArea.Children.Add(graphLine)
End Sub
Заключение
Это был по-настоящему интересный проект, написание кода для которого отняло у меня куда меньше времени, чем нынешние пояснения. Он также является отличным упражнением для мозгов, потому что перед кодированием требует некоторого изучения соответствующей области науки, а ведь именно в этом и заключен весь смысл кодирования для развлечения!
Если вы хотите опробовать этот проект в деле, то ссылка для его загрузки приведена в самом начале статьи.
Обавторе
Дэн Уотерс (Dan Waters) — Academic Evangelist в Microsoft, курирующий учебные заведения на Тихоокеанском Северо-Западе, Аляске и на Гавайях. Живет в городке Беллвью, штат Вашингтон. У Дэна чересчур много гитар дома, и он пытается подтолкнуть обеих своих юных дочерей к обучению игре на них. У него далеко не одно хобби — музыка, технологии и музыка + технологии, а также катание на сноуборде и желание поддержать статус крутого папы. Вы найдете его блог на www.danwaters.com или на Twitter по ссылке www.twitter.com/danwaters.
Comments
- Anonymous
October 16, 2012
Хорошая статья. Как раз в планах интересный проект, но для его реализации не хватало простого примера, который бы показывал принципы на деле.