Рисуем волну .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()

Еще примеры:

+65
6 февраля 2011, 14:05
115
igrishaev 40,4

комментарии (38)

–14
ArtemSmirnov #
нарисовать фолну

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

А вообще мне очень нравится, что с wave работать легко и просто, например нормально (для любых wav) повернуть звук задом наперёд занимает всего примерно 10 строк.
0
Robotex #
Мне вот интересно, а как сгенерировать волну (например, от клавиши пианино) и записать ее в wave-файл.
+1
reallynoideas #
Я бы использовал мат. аппарат из тории струн. Надо попробовать, хорошая идея )
0
Robotex #
Я надеюсь не той теории струн, что часть М-теории из физики :)
0
igrishaev #
В модуле wave есть класс для записи wav-файла. В гугле можно найти примеры, как генерируют вейвы с шумом на основе random. А как читать звук с девайсов, к сожалению, не знаю.
0
LeoMat #
Читать можно, например, с помощью pymedia.audio.sound. В tutorial есть пример voice recoder.
–1
equand #
440 гц :) = до, вроде так, не помню уже
там кучу гармоник еще делать, звучание струны + отражения других струн ну и реверберация внутри коробки.
Забейте, в наше время хиты делаются коктейлем white trash poser + autotune
+3
orybak #
вроде 440гц — это ля первой октавы.
+1
stryaponoff #
Интересно, а звук в питоне получать со звуковухи можно? То есть, реально ли написать, например, тюнер с помощью каких-то уже реально существующих библиотек, как это сделано тут?
+2
LeoMat #
Конечно, можно. Например, с помощью pymedia.
0
stryaponoff #
Спасибо, попробую поковыряться, всегда было интересно что-нибудь такое написать
0
seriyPS #
Можно вроде GStreamer pygst модуль зовется. Правда под win вроде не очень работает.
+2
eisernWolf #
А вы как-нибудь сверяли вашую волно-форму с эталоном? Просто обычно они вот так выглядят: www.floom.com/images/waveform_gallery.htm У вас нехарактерный рисунок. Поэтому и спрашиваю. Код читать как-то лень, но выглядит, как будто график имеет гораздо меньшую частоту дискретизации, чем обычно имеют музыкальные файлы.
0
igrishaev #
Конечно, я сверял график с другими программами.
Изображение с программы SoundBooth:


Мой график:


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

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

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

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

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

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