Генерация звуковых волн с помощью волнового осциллятора на 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).

clip_image002

Вообще-то я скверный дизайнер UI, и у меня получилось нечто, что по крайне мере позволяет работать с этим приложением. Но вы не стесняйтесь и изменяйте его так, чтобы он выглядел и работал лучше!

Разработка UI

В этом приложении есть один маленький секрет. Заявлено, что оно может генерировать три волны, но по правде, это определяется константой (равной 3), которую можно изменить. При желании вы могли бы генерировать и шесть волн. Как я сделал это? Каждый синтезатор, который вы видите, является экземпляром пользовательского элемента управления WPF под названием Oscillator.xaml:

clip_image002[4]

В основное окно я поместил 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 выборок в секунду.

clip_image002[6]

Ранее я отметил, что звуковой файл фактически является очень-очень длинным массивом 16- или 32-разрядных чисел с плавающей точкой в диапазоне от –1 до 1. Мы используем эти данные и для построения графика, но все подробности позже.

Теперь, когда у нас есть какой-никакой UI (обеспечивающий динамическое добавление осцилляторов), давайте рассмотрим, как же создается звук.

Создание звуков и микшер

Одно из многих классных качеств DirectSound заключается в том, что этот интерфейс умеет обертывать формат WAV, скрывая его от вас. Вы задаете параметры буферизации и формата, а потом посылаете ему порцию данных, и музыка играет. Магия.

Я предпочел чуть более модульную архитектуру для своего решения. Ни один осциллятор сам по себе ничего не воспроизводит — он использует свой UI для управления некоторыми значениями, такими как частота, амплитуда и тип волны. Эти значения связываются с открытыми свойствами. Компонент Oscillator не делает практически ничего, что можно было бы отнести к созданию аудиоданных.

Генерация аудиоданных обрабатывается собственным классом Mixer, который принимает набор Oscillators и, исходя из значений свойств объектов в наборе, формирует суммарный вывод всех генераторов. Это осуществляется усреднением выборок в каждом осцилляторе и их записью в новый массив данных.

 Fig1_f

Класс Mixer выглядит так:

clip_image002[8]

Одна из рабочих лошадок класса 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.