Рисуем волну .wav-файла


    Некоторое время назад я решил посвятить себя решению экзотической задачи — нарисовать волну wave-файла, как это делают аудио- и видеоредакторы, используя для этого Питон. В результате у меня получился небольшой скрипт, который вполне с этим справляется. Так, картинка выше сгенерирована им из песни «Under Pressure» группы Queen. Для сравнения — вид волны в аудиоредакторе:

    Для разбора звука я использовал библиотеку numpy, а для построения графика — matplotlib. Под катом я изложу основы работы с wav-файлами и алгоритм скрипта.

    UPD1: коэффициент прореживания k лучше брать примерно k = nframes/w/32, подобрал эмпирически. Обновил картинки с новым коэффициентом.

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

    Из заголовочной части извлекаются основные параметры — число каналов, битрейт, число фреймов — на основании которых осуществляется разбор аудиопотока. Wave-файл хранит в себе 1 или 2 канала, каждый из которых кодируется 8, 16, 24 или 32 битами. Последовательность бит, описывающая амплитуду волны в момент времени, называется сэмплом. Последовательность сэмплов для всех каналов в определенный момент называется фреймом.

    Например, \xe2\xff\xe3\xfа — это фрейм 16-битного wav-файла. Значит, \xe2\xff — сэмпл первого (левого) канала, а \xe3\xfа — второго (правого). Сэмплы представляют собой целые знаковые числа (исключение — файлы с сэмплами в 8 бит, беззнаковые числа).

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

    import wave
    wav = wave.open("music.wav", mode="r")
    (nchannels, sampwidth, framerate, nframes, comptype, compname) = wav.getparams()
    content = wav.readframes(nframes)
    

    Этими строками мы создаем объект для чтения wav-файла (если параметр «r» опустить, то будет создан объект для записи, что нам не подходит). Метод getparams() возвращает кортеж основных параметров файла (по порядку): число каналов, число байт на сэмпл, число вреймов в секунду, общее число фреймов, тип сжатия, имя типа сжатия. Я вынес их всё в отдельные переменные, чтобы не обращаться каждый раз к полям объекта.

    Метод readframes() считывает указанное число фреймов относительно внутреннего указателя объекта и инкрементирует его. В данном случае, мы за один раз считали все фреймы в одну байтовую строку в переменную content.

    Теперь нужно разобрать эту строку. Параметр sampwidth определяет, сколько байт уходит на кодирование одного сэмпла:
    • 1 = 8 бит, беззнаковое целое (0-255),
    • 2 = 16 бит, знаковое целое (-32768-32767)
    • 4 = 32 бит, знаковое длинное целое (-2147483648-2147483647)

    Разбор осуществляется следующим образом:

    import numpy as np
    types = {
        1: np.int8,
        2: np.int16,
        4: np.int32
    }
    samples = np.fromstring(content, dtype=types[sampwidth])
    

    Здесь задействуется библиотека numpy. Ее основное предназначение — математические действия с массивами и матрицами. Numpy оперирует своими собственными типами данных. Функция fromstring() создает одномерный массив из байтовой строки, при этом параметр dtype определяет, как будут интерпретированы элементы массива. В нашем примере, тип данных берется из словаря «types», в котором сопоставлены размеры сэмпла и типы данных numpy.

    Теперь у нас есть массив сэмплов аудиопотока. Если в нем один канал, весь массив будет представлять его, если два (или несколько), то нужно «проредить» массив, выбрав для каждого канала каджый n-ый элемент:

    for n in range(nchannels):
        channel = samples[n::nchannels]
    

    В этом цикле в массив channel выбирается каждый аудиоканал при помощи среза вида [offset::n], где offset — индекс первого элемента, а n — шаг выборки. Но массив канала содержит огромное количество точек, и вывод графика для 3-минутного файла потребует огромных затрат памяти и времени. Введем в код некоторые дополнительные переменные:
    duration = nframes / framerate 
    w, h = 800, 300
    DPI = 72
    peak = 256 ** sampwidth / 2
    k = nframes/w/32
    


    duration — длительность потока в секундах, w и h — ширина и высота выходного изображения, DPI — произвольное значение, необходимое для перевода пикселей в дюймы, peak — пиковое значение амплитуды сэмпла, k — коэффициент прореживания канала, зависящий от ширины изображения; подобран эмпирически.

    Скорректируем отображение графика:
    plt.figure(1, figsize=(float(w)/DPI, float(h)/DPI), dpi=DPI)
    plt.subplots_adjust(wspace=0, hspace=0)
    

    Теперь цикл с выводом каналов будет выглядеть так:
    for n in range(nchannels):
        channel = samples[n::nchannels]
    
        channel = channel[0::k]
        if nchannels == 1:
            channel = channel - peak
    
        axes = plt.subplot(2, 1, n+1, axisbg="k")
        axes.plot(channel, "g")
        axes.yaxis.set_major_formatter(ticker.FuncFormatter(format_db))
        plt.grid(True, color="w")
        axes.xaxis.set_major_formatter(ticker.NullFormatter())
    

    В цикле делается проверка на число каналов. Как я уже говорил, 8-битный звук хранится в беззнаковых целых, поэтому его необходимо нормализовать, отняв от каждого сэмпла половину амплитуды.

    Напоследок, установим формат нижней оси
    axes.xaxis.set_major_formatter(ticker.FuncFormatter(format_time))
    

    Сохраним график в картинку и покажем его:
    plt.savefig("wave", dpi=DPI)
    plt.show()
    


    format_time и format_db — это функции для форматирования значений шкал осей абсцисс и ординат.

    format_time форматирует время по номеру сэмпла:
    def format_time(x, pos=None):
        global duration, nframes, k
        progress = int(x / float(nframes) * duration * k)
        mins, secs = divmod(progress, 60)
        hours, mins = divmod(mins, 60)
        out = "%d:%02d" % (mins, secs)
        if hours > 0:
            out = "%d:" % hours
        return out
    

    Функция format_db форматирует громкость звука по его амплитуде:
    def format_db(x, pos=None):
        if pos == 0:
            return ""
        global peak
        if x == 0:
            return "-inf"
    
        db = 20 * math.log10(abs(x) / float(peak))
        return int(db)
    

    Весь скрипт:
    import wave
    import numpy as np
    import matplotlib.pyplot as plt
    import matplotlib.ticker as ticker
    import math
    
    types = {
        1: np.int8,
        2: np.int16,
        4: np.int32
    }
    
    def format_time(x, pos=None):
        global duration, nframes, k
        progress = int(x / float(nframes) * duration * k)
        mins, secs = divmod(progress, 60)
        hours, mins = divmod(mins, 60)
        out = "%d:%02d" % (mins, secs)
        if hours > 0:
            out = "%d:" % hours
        return out
    
    def format_db(x, pos=None):
        if pos == 0:
            return ""
        global peak
        if x == 0:
            return "-inf"
    
        db = 20 * math.log10(abs(x) / float(peak))
        return int(db)
    
    wav = wave.open("music.wav", mode="r")
    (nchannels, sampwidth, framerate, nframes, comptype, compname) = wav.getparams()
    
    duration = nframes / framerate
    w, h = 800, 300
    k = nframes/w/32
    DPI = 72
    peak = 256 ** sampwidth / 2
    
    content = wav.readframes(nframes)
    samples = np.fromstring(content, dtype=types[sampwidth])
    
    plt.figure(1, figsize=(float(w)/DPI, float(h)/DPI), dpi=DPI)
    plt.subplots_adjust(wspace=0, hspace=0)
    
    for n in range(nchannels):
        channel = samples[n::nchannels]
    
        channel = channel[0::k]
        if nchannels == 1:
            channel = channel - peak
    
        axes = plt.subplot(2, 1, n+1, axisbg="k")
        axes.plot(channel, "g")
        axes.yaxis.set_major_formatter(ticker.FuncFormatter(format_db))
        plt.grid(True, color="w")
        axes.xaxis.set_major_formatter(ticker.NullFormatter())
    
    axes.xaxis.set_major_formatter(ticker.FuncFormatter(format_time))
    plt.savefig("wave", dpi=DPI)
    plt.show()
    

    Еще примеры:

    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 39
    • –14
      нарисовать фолну

      Поправьте в первом предложении.
      • –5
        Почему вы не пользуетесь ПМ?
        • –16
          Потому что автор не пользуется проверкой орфографии в браузере.
      • +1
        Почему struct.unpack не пользовали, а нампи?
        • +2
          Да, можно парсить через struct, не спорю. Но я где-то читал (к сожалению, не помню, где), что mathplotlib заточена под numpy, выше производительность по сравнению со стандартными списками. И кроме того, если со звуком будут выполняться какие-либо манипуляции (нормализация, фильтры, компрессия, преобразования Фурье), то лучше numpy-массивов ничего лучше быть не может.
          • +1
            Да, в том и дело, что в numpy массивы, а не списки. А массивы и существуют для обработки больших данных однородной информации. Кстати, вы повторяете ошибку и в статье, и в этом комментарии: Matplotlib пишется без h.
        • +1
          Меня топик чем-то зацепил, наверное я бессознательно тоже хочу waveform-ы на python рисовать :)
          Интересно, что-то из готового пробовали приспособить?
          С ходу гуглится такое и вот такое.
          А еще scipy.io.wavfile.read.
          • 0
            Да, тем более что scipy.io.wavfile как раз возвращает numpy array.
            • 0
              Пробывал scipy.io.wavfile, у меня почему-то выдавало ошибку на некоторых файлах. Решил отложить scipy на потом.
            • 0
              Спасибо!
              P.S: В скрипте удалите «r» в первом param:
              wav = wave.open(r«music.wav», mode=«r»)
              • 0
                и в четвертом абзаце
                а xe3\xfа — второго (правого).
              • 0
                Хороший выбор средств.

                А вообще мне очень нравится, что с wave работать легко и просто, например нормально (для любых wav) повернуть звук задом наперёд занимает всего примерно 10 строк.
                • НЛО прилетело и опубликовало эту надпись здесь
                  • НЛО прилетело и опубликовало эту надпись здесь
                    • НЛО прилетело и опубликовало эту надпись здесь
                    • 0
                      В модуле wave есть класс для записи wav-файла. В гугле можно найти примеры, как генерируют вейвы с шумом на основе random. А как читать звук с девайсов, к сожалению, не знаю.
                    • –1
                      440 гц :) = до, вроде так, не помню уже
                      там кучу гармоник еще делать, звучание струны + отражения других струн ну и реверберация внутри коробки.
                      Забейте, в наше время хиты делаются коктейлем white trash poser + autotune
                      • +3
                        вроде 440гц — это ля первой октавы.
                    • +1
                      Интересно, а звук в питоне получать со звуковухи можно? То есть, реально ли написать, например, тюнер с помощью каких-то уже реально существующих библиотек, как это сделано тут?
                      • +2
                        Конечно, можно. Например, с помощью pymedia.
                        • 0
                          Спасибо, попробую поковыряться, всегда было интересно что-нибудь такое написать
                        • 0
                          Можно вроде GStreamer pygst модуль зовется. Правда под win вроде не очень работает.
                        • НЛО прилетело и опубликовало эту надпись здесь
                          • 0
                            Конечно, я сверял график с другими программами.
                            Изображение с программы SoundBooth:


                            Мой график:


                            Видимо, меня подвел коэфициент k для прореживания графика. Чем он больше (ближе к числу сэмплов), тем лучше график.
                            • 0
                              Обновил код с новым коэффициентом.
                              • 0
                                ток акуратнее, на коротеньких файлах коэффициент может стать 0 а это вызовет ValueError: slice step cannot be zero
                          • +2
                            Слушайте, в этом скрипте нужно загружать весь файл в память… Соответственно очень большие файлы так не обработаешь ибо память закончится.

                            Можно-ли как то переделать на потоковую обработку? Считывать и обрабатывать пофреймово допустим...?
                            • +1
                              content = wav.readframes(nframes)
                              прочтите сколько надо фреймов, меняйте переменную nframes под количество байт в нем, читайте, принудительно удаляйте объекты или перезаписывайте буфер content.
                              • 0
                                Это я заметил, но мне кажется что будут проблемы с калибровкой высоты графика, ведь нужно макс. амплитуду знать чтобы разрешение по Y оси задать…
                                Можно в 2 прохода сделать конечно, но не очень приятная перспектива.
                                • НЛО прилетело и опубликовало эту надпись здесь
                                  • 0
                                    256^sampwidth чтоль? Переменная peak?
                                    Если да, то ок, принимается. (Что за магическая константа 256?)

                                    Хотя исходный скрипт довольно сильно переделывать придется. Надо будет заняться на досуге.

                                    А matplotlib умеет данные по кусочкам принимать? (хотя после разрежения channel = channel[0::k] там объем данных очень сильно сократится, так что главное ДО процедуры разрежения все потоково проделать)

                                    Да, надо будет заняться на досуге…
                                    • +1
                                      peak — это пиковое значение амплитуды. Зная, сколько байт выделяется под один сэмпл, можно высчитать ее максимальное значение: 256 (макс. амплитуда для 1 байта) возвести в степень числа байт на сэмпл и разделить пополам.
                                      А по поводу чтения по кусочкам — совершенно верно, но это уже дальнейшая оптимизация.
                            • –1
                              А есть возможность получить громкость в дб в определённый момент времени? Грубо говоря сделать цикл от 0 сек до 60 сек?
                              • 0
                                Функция format_db, приведенная в коде, возвращает громкость в децибелах по амплитуде.
                              • 0
                                По коду похоже, что внутри конкретного фрейма и канала цифры обозначают только амплитуду. Как (и где) тогда в wav-файле хранится распределение по частотам?
                                • 0
                                  Распределение по частотам нигде не хранится, эти данные извлекаются аналитически. Сейчас работаю над этим.
                                  Гуглите по словам «wave frequency analyze»
                                  • 0
                                    Не гуглил, но кажется понял. У нас распределение по частотам образуется изменением амплитуды во времени (по фреймам). То есть, если нам нужна скажем одна волна частотой 1КГц, мы можем завести по 2000 фреймов в секунду и в каждом фрейме менять знак амплитуды. Так можно задать произвольную комбинацию волн, складывая амплитуды, соответствующие каждой волне, в каждом фрейме (а получить волны «обратно» можно каким-нибудь преобразованием Фурье).
                                • 0
                                  А вы пробовали работать с 24-битными файлами? python.wave ведет себя странно — sample width == 3, но len(sample.readframes(1)) == 6

                                  Похоже на баг библиотеки

                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.