Дисклеймер: Я не рассматриваю какие-либо алгоритмы и API для работы со звуком и распознаванием речи. Эта статья о проблемах при работе с аудио и об их решении с помощью Go.

gopher


phono — прикладной фреймворк для работы со звуком. Его основная функция — создать конвейер из разных технологий, который обработает звук за вас нужным вам образом.


При чём тут конвейер, к тому же из разных технологий и зачем ещё один фреймворк? Сейчас разберёмся.


Откуда звук?


К 2018 году звук стал стандартным способом взаимодействия человека с технологиями. Большинство IT-гигантов создали своего голосового помощника или делают это прямо сейчас. Голосовое управление уже есть в большинстве операционных систем, а голосовые сообщения — типичная функция любого мессенджера. В мире около тысячи стартапов работают над обработкой естественного языка и около двух сотен над распознаванием речи.


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


Общие задачи


Если вам приходилось работать со звуком, то следующие условия должны звучать знакомо:


  • Аудио надо получить из файла, устройства, сети и т.д.
  • Аудио надо обработать: добавить эффекты, перекодировать, проанализировать и т.п.
  • Аудио надо передать в файл, устройство, сеть и т.д.
  • Данные передаются небольшими буферами

Получается обычный конвейер — есть поток данных, который проходит несколько стадий обработки.


Решения


Для наглядности, возьмём задачу из реальной жизни. Например, нужно преобразовать голос в текст:


  • Записываем аудио с устройства
  • Удаляем шумы
  • Эквализируем
  • Передаём сигнал в API для распознавания речи

Как и любая другая задача, эта имеет несколько решений.


В лоб


Только для хардкорных велосипедистов программистов. Записываем звук непосредственно через драйвер звуковой карты, пишем умный шумодав и многополосный эквалайзер. Это очень интересно, но можно на несколько месяцев забыть о своей изначальной задаче.


Долго и очень сложно.


По-нормальному


Альтернатива — использовать существующие API. Записать аудио можно с помощью ASIO, CoreAudio, PortAudio, ALSA и прочих. Для обработки тоже есть несколько видов плагинов: AAX, VST2, VST3, AU.


Богатый выбор не означает, что можно использовать всё сразу. Обычно действуют следующие ограничения:


  1. Операционная система. Не все API доступны на всех операционных системах. Например, AU — нативная технология OS X и доступна только там.
  2. Язык программирования. Большинство аудио библиотек написаны на С или С++. В 1996 году компания Steinberg выпустила первую версию VST SDK, до сих пор самый популярный стандарт плагинов. Спустя 20 лет уже не обязательно писать на С/С++: для VST есть обёртки на Java, Python, C#, Rust и кто знает на чём еще. Хоть язык и остаётся ограничением, но теперь звук обрабатывают даже на JavaScript.
  3. Функционал. Если задача простая и понятная, не обязательно писать новое приложение. Тот же FFmpeg умеет очень многое.

В этой ситуации сложность зависит от вашего выбора. В худшем случае придётся иметь дело с несколькими библиотеками. И если совсем не повезёт, со сложными абстракциями и абсолютно разными интерфейсами.


Что в итоге?


Нужно выбирать между очень сложным и сложным:


  • либо иметь дело с несколькими низкоуровневыми API, чтобы писать свои велосипеды
  • либо иметь дело с несколькими API и пытаться их подружить

Неважно какой способ выбран, задача всегда сводится к конвейеру. Используемые технологии могут отличаться, но суть неизменна. Проблема в том, что опять вместо решения реальной задачи, приходиться писать велосипед конвейер.


Но есть выход.


phono


phono


phono создан, чтобы решить общие задачи — "получить, обработать и передать" звук. Для этого он использует конвейер, как самую естественную абстракцию. В официальном блоге Go есть статья, которая описывает паттерн pipeline (англ. конвейер). Главная идея pipeline в том, что есть несколько стадий обработки данных, которые работают независимо друг от друга и обмениваются данными через каналы. То, что надо.


А почему Go?


Во-первых, большинство аудио программ и библиотек написаны на C, а Go часто упоминается в качестве его преемника. К тому же, есть cgo и довольно много биндингов для существующих аудио библиотек. Можно брать и пользоваться.


Во-вторых, по моему личному мнению, Go — хороший язык. Не буду углубляться, но отмечу его многопоточность. Каналы и горутины сильно упрощают реализацию конвейера.


Абстракции


Сердцем phono является тип pipe.Pipe (англ. труба). Именно он реализует pipeline. Как и в образце из блога, предусмотрено три вида стадий:


  1. pipe.Pump (англ. насос) — получение звука, только выходные каналы
  2. pipe.Processor (англ. обработчик) — обработка звука, входные и выходные каналы
  3. pipe.Sink (англ. раковина) — передача звука, только входные каналы

Внутри pipe.Pipe данные передаются буферами. Правила, по которым можно строить pipeline:


pipe_diagram


  1. Один pipe.Pump
  2. Несколько pipe.Processor, размещённых последовательно друг за другом
  3. Один или несколько pipe.Sink, размещённых параллельно
  4. Все компоненты pipe.Pipe должны иметь одинаковые:
    • Размер буфера (сообщения)
    • Частоту дискретизации
    • Число каналов

Минимальная конфигурация — Pump и один Sink, остальное опционально.


Разберём несколько примеров.


Простой


Задача: воспроизвести wav файл.


Приведём её к виду "получить, обработать, передать":


  1. Получаем аудио из wav файла
  2. Передаём аудио в portaudio устройство


Аудио считывается и сразу воспроизводится.


Код
package example

import (
    "github.com/dudk/phono"
    "github.com/dudk/phono/pipe"
    "github.com/dudk/phono/portaudio"
    "github.com/dudk/phono/wav"
)

// Example:
//      Read .wav file
//      Play it with portaudio
func easy() {
    wavPath := "_testdata/sample1.wav"
    bufferSize := phono.BufferSize(512)
    // wav pump
    wavPump, err := wav.NewPump(
        wavPath,
        bufferSize,
    )
    check(err)

    // portaudio sink
    paSink := portaudio.NewSink(
        bufferSize,
        wavPump.WavSampleRate(),
        wavPump.WavNumChannels(),
    )

    // build pipe
    p := pipe.New(
        pipe.WithPump(wavPump),
        pipe.WithSinks(paSink),
    )
    defer p.Close()

    // run pipe
    err = p.Do(pipe.Run)
    check(err)
}

Сначала мы создаём элементы будущего конвейера: wav.Pump и portaudio.Sink и передаём их в конструктор pipe.New. Функция p.Do(pipe.actionFn) error запускает конвейер и ждёт окончания работы.


Сложнее


Задача: разбить wav файл на семплы, составить из них трек, результат сохранить и одновременно воспроизвести.


Трек — это последовательность семплов, а семпл — небольшой отрезок аудио. Чтобы аудио можно было резать, нужно сначала загрузить его в память. Для этого используем тип asset.Asset из пакета phono/asset. Разбиваем задачу на стандартные шаги:


  1. Получаем аудио из wav файла
  2. Передаём аудио в память

Теперь руками делаем семплы, добавляем их в трек и добиваем задачу:


  1. Получаем аудио из трека
  2. Передаём аудио в
    • wav файл
    • portaudio устройство

example_normal


Снова, без стадии обработки, зато целых два pipeline!


Код
package example

import (
    "github.com/dudk/phono"
    "github.com/dudk/phono/asset"
    "github.com/dudk/phono/pipe"
    "github.com/dudk/phono/portaudio"
    "github.com/dudk/phono/track"
    "github.com/dudk/phono/wav"
)

// Example:
//      Read .wav file
//      Split it to samples
//      Put samples to track
//      Save track into .wav and play it with portaudio
func normal() {
    bufferSize := phono.BufferSize(512)
    inPath := "_testdata/sample1.wav"
    outPath := "_testdata/example4_out.wav"

    // wav pump
    wavPump, err := wav.NewPump(inPath, bufferSize)
    check(err)

    // asset sink
    asset := &asset.Asset{
        SampleRate: wavPump.WavSampleRate(),
    }

    // import pipe
    importAsset := pipe.New(
        pipe.WithPump(wavPump),
        pipe.WithSinks(asset),
    )
    defer importAsset.Close()
    err = importAsset.Do(pipe.Run)
    check(err)

    // track pump
    track := track.New(bufferSize, asset.NumChannels())

    // add samples to track
    track.AddFrame(198450, asset.Frame(0, 44100))
    track.AddFrame(66150, asset.Frame(44100, 44100))
    track.AddFrame(132300, asset.Frame(0, 44100))

    // wav sink
    wavSink, err := wav.NewSink(
        outPath,
        wavPump.WavSampleRate(),
        wavPump.WavNumChannels(),
        wavPump.WavBitDepth(),
        wavPump.WavAudioFormat(),
    )
    // portaudio sink
    paSink := portaudio.NewSink(
        bufferSize,
        wavPump.WavSampleRate(),
        wavPump.WavNumChannels(),
    )

    // final pipe
    p := pipe.New(
        pipe.WithPump(track),
        pipe.WithSinks(wavSink, paSink),
    )

    err = p.Do(pipe.Run)
}

По сравнению с прошлым примером, есть два pipe.Pipe. Первый передаёт данные в память, чтобы можно было нарезать семплы. Второй имеет сразу два получателя в конце: wav.Sink и portaudio.Sink. При такой схеме звук одновременно записывается в wav файл и воспроизводится.


Еще сложнее


Задача: прочитать два wav файла, смешать, обработать vst2 плагином и сохранить в новый wav файл.


В пакете phono/mixer есть простой миксер mixer.Mixer. В него можно передать сигналы из нескольких источников и получить один смиксованный. Для этого он одновременно реализует pipe.Pump и pipe.Sink.


Опять задача состоит из двух подзадач. Первая выглядит так:


  1. Получаем аудио wav файла
  2. Передаём аудио в миксер

Вторая:


  1. Получаем аудио из миксера
  2. Обрабатываем аудио плагином
  3. Передаём аудио в wav файл

example_hard


Код

package example

import (
    "github.com/dudk/phono"
    "github.com/dudk/phono/mixer"
    "github.com/dudk/phono/pipe"
    "github.com/dudk/phono/vst2"
    "github.com/dudk/phono/wav"
    vst2sdk "github.com/dudk/vst2"
)

// Example:
//      Read two .wav files
//      Mix them
//      Process with vst2
//      Save result into new .wav file
//
// NOTE: For example both wav files have same characteristics i.e: sample rate, bit depth and number of channels.
// In real life implicit conversion will be needed.
func hard() {
    bs := phono.BufferSize(512)
    inPath1 := "../_testdata/sample1.wav"
    inPath2 := "../_testdata/sample2.wav"
    outPath := "../_testdata/out/example5.wav"

    // wav pump 1
    wavPump1, err := wav.NewPump(inPath1, bs)
    check(err)

    // wav pump 2
    wavPump2, err := wav.NewPump(inPath2, bs)
    check(err)

    // mixer
    mixer := mixer.New(bs, wavPump1.WavNumChannels())

    // track 1
    track1 := pipe.New(
        pipe.WithPump(wavPump1),
        pipe.WithSinks(mixer),
    )
    defer track1.Close()
    // track 2
    track2 := pipe.New(
        pipe.WithPump(wavPump2),
        pipe.WithSinks(mixer),
    )
    defer track2.Close()

    // vst2 processor
    vst2path := "../_testdata/Krush.vst"
    vst2lib, err := vst2sdk.Open(vst2path)
    check(err)
    defer vst2lib.Close()

    vst2plugin, err := vst2lib.Open()
    check(err)
    defer vst2plugin.Close()

    vst2processor := vst2.NewProcessor(
        vst2plugin,
        bs,
        wavPump1.WavSampleRate(),
        wavPump1.WavNumChannels(),
    )

    // wav sink
    wavSink, err := wav.NewSink(
        outPath,
        wavPump1.WavSampleRate(),
        wavPump1.WavNumChannels(),
        wavPump1.WavBitDepth(),
        wavPump1.WavAudioFormat(),
    )
    check(err)

    // out pipe
    out := pipe.New(
        pipe.WithPump(mixer),
        pipe.WithProcessors(vst2processor),
        pipe.WithSinks(wavSink),
    )
    defer out.Close()

    // run all
    track1Done, err := track1.Begin(pipe.Run)
    check(err)
    track2Done, err := track2.Begin(pipe.Run)
    check(err)
    outDone, err := out.Begin(pipe.Run)
    check(err)

    // wait results
    err = track1.Wait(track1Done)
    check(err)
    err = track2.Wait(track2Done)
    check(err)
    err = out.Wait(outDone)
    check(err)
}

Здесь уже три pipe.Pipe, все связанные между собой через миксер. Для запуска используется функция p.Begin(pipe.actionFn) (pipe.State, error). В отличии от p.Do(pipe.actionFn) error, она не блокирует вызов, а просто возвращает состояние, которое потом можно дождаться с помощью p.Wait(pipe.State) error.


Что дальше?


Я хочу, чтобы phono стал максимально удобным прикладным фреймворком. Если есть задача со звуком, не нужно разбираться в сложных API и тратить время на изучение стандартов. Всё, что надо — построить конвейер из подходящих элементов и запустить его.


За пол года запилены следующие пакеты:


  • phono/wav — читать/писать wav файлы
  • phono/vst2 — неполные биндинги VST2 SDK, пока можно только открывать плагин и вызывать его методы, но нет всех структур
  • phono/mixer — миксер, складывает N сигналов, без баланса и громкости
  • phono/asset — семплирование буферов
  • phono/track — последовательное считывание семплов (разрулены наслоения)
  • phono/portaudio — воспроизведение сигнала, пока эксперименты

Кроме этого списка, есть постоянно толстеющий бэклог из новых идей и задумок, среди которых:


  • Отсчёт времени
  • Изменяемый на лету pipeline
  • HTTP pump/sink
  • Автоматизация параметров
  • Ресемплинг-процессор
  • Баланс и громкость в миксере
  • Real-time pump
  • Синхронизированный pump для нескольких треков
  • Полноценный vst2

В следующих статьях я разберу:


  • жизненный цикл pipe.Pipe — из-за сложной структуры его состояние управляется конечным атоматом
  • как писать свои стадии конвейера

Это мой первый open-source проект, так что я буду благодарен любой помощи и рекомендациям. Добро пожаловать.


Ссылки


Комментарии (12)


  1. Yeah
    27.09.2018 16:39

    А как насчёт декодеров, например, mp3? Получается, что нужно писать адаптер для phono? Типа mp3 -> wav -> phono


    1. dudk Автор
      27.09.2018 17:08

      Я пока не реализовывал декодеры, но по идее любой аудио-формат — это потенциальный источник и пункт назначения для аудио данных. Соответственно, пакет mp3 мог бы иметь Pump и Sink реализации чтобы читать и писать файлы. Аналогично wav. Если у исходного файла подходящая частота дискретизации и число каналов — можно брать и использовать.


      1. Yeah
        28.09.2018 15:26

        А зачем для wav нужен Pump? Неужели чтение wav файла будет отличаться от чтения mp3? Как по мне логично было бы иметь File Pump, Http Pump, memory Pump, а декодер должен уже действовать как Processor. Аналогично Sink


        1. dudk Автор
          29.09.2018 19:00

          Вы правы, такая организация кода тоже может быть. Для конечного пользователя все станет немного проще, но есть несколько трудностей:


          1. Компоненты будут содержать код для определения, какую технологию использовать.
          2. Пакеты будут содержать очень много зависимостей.
          3. Декодер не получится использовать как процессор, потому что все данные в pipe.Pipe передаются в float64, а для такой задачи нужно работать непосредственно с массивами байт.

          Часть этих проблем являются следствием ограничений в Go. Кроме того, деление пакетов по технологиям является идиоматическим и можно часто встретить в стандартной библиотеке.


  1. Stas911
    27.09.2018 22:31

    Прикольно — такой себе Apache Storm для звука


  1. thecoder
    28.09.2018 11:40

    Отличная идея для проекта. Даже если бы его не было, чужие api так и просятся быть завернутыми в объекты, чтобы с помощью композиции оформлять разные виды обработок.


    Как обычно мучает вопрос об открывшихся прикладных применениях. Можете, пожалуйста, в следующих сериях больше рассказать об идеях, что можно в принципе интересного делать со звуком? В применении для веба. Кроме банального перекодирования и наложения музыки фоном, в голову ничего не приходит.


    1. dudk Автор
      28.09.2018 16:14
      +1

      Спасибо за комментарий. Из реальных сценариев в голову приходят голосовой чат и аудио-стриминг. Я хотел сделать демо, где играл бы небольшой отрезок аудио и можно было бы в real-time накидывать на него разные эффекты. Такой server-side processing для аудио. Пока отложил в ящик из-за сложного (для меня) клиента.


      В целом, клиент-серверное взаимодействие есть в планах и обязательно будет описано по мере запиливания.


  1. aibread
    28.09.2018 12:11

    а как генерировать звук? например осциллятор обычный


    1. dudk Автор
      28.09.2018 12:53

      Я думал о генераторах и как их реализовать для удобного использования. Помимо осциллятора я бы отнес к генераторам звука запись сигнала через аудио интерфейс. Здесь, наверняка, надо будет добавить какие то общие для всех генераторов параметры. Например, длительность генерации или возможность прервать ее «вручную». Как и любой другой источник, осциллятор должен будет реализовывать pipe.Pump. Конкретно — pipe.Pump с нужными для осцилляторами параметрами: форма волны, частота, амплитуда и т.д. К сожалению, я не очень знаком с генерацией сигналов, но было бы очень интересно реализовать.


  1. domix32
    28.09.2018 12:20
    +1

    Go часто упоминается в качестве его преемника

    Что простите? Я б понял если бы назвали преемником Java, но C?


    1. oldbay
      28.09.2018 13:01

      Просто автор считает — что если язык компилируемый и многопоточный — то это конечно сразу не менее чем приемник ANSI C, ну или С++ на худой конец. И теперь его нужно пихать во все возможные щели в любом виде — лишь бы оно было написано на go!
      Видимо google «нехило башляет» за агрессивное «пропихивание» своего «поделия» во все отрасли «народного хозяйства», так что мы ещё увидим много статей из разряда: «давайте ловить мух в go», «давайте рисовать слонов в go», «давайте перепишем весь софт на go»… На подобные статьи хочется ответить: а давайте не будем… пихать не предназначенные для этого вещи во все дыры.


      1. dudk Автор
        28.09.2018 15:06
        +1

        <sarcasm>Пойду проверю свой счет в банке.</sarcasm>


        Если серьезно, то я просто нашел интересную мне задачу и решил ее языком, который мне нравится. Решение показалось мне интересным, я создал фреймворк и теперь хочу его развивать. Не считаю что какой то язык лучше другого. Каждому свое применение.