Пример аудиоплеера с визуализацией на Qt

Изменено: . Разместил: FadeDemon | Меню
Количество просмотров2`574 / FD2413-6-0

Надо сказать, мне всегда нравилось погружаться в прослушивание музыки, сопровождаемое красивой визуализацией. Когда-то я даже по этой причине открыл для себя плагин WinAmp'а — MilkDrop, а некоторое время спустя даже написал плагин для WinAmp'а, позволяющий управлять импровизированной “светомузыкой”, представляющей собой 8 светодиодов, подключенных к LPT порту. В те времена я не особенно понимал, как выглядят визуализаторы изнутри… И вот, относительно недавно, у меня возникла необходимость сдать какой-нибудь курсач, содержащий в себе Qt. Здесь меня и посетила мысль, а почему-бы мне не попробовать написать свой визуализатор? И не в виде плагина для чего-то, а в виде самодостаточного плеера. Мысль мне понравилась…


MilkDrop улыбается тебе
Рис. №1223. MilkDrop улыбается тебе. Ну или нет.

Концепция


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


Спектр аудиосигнала
Рис. №1221. Пример спектра

Спектр аудиосигнала, в частности музыки, довольно интересная штука. По спектру проигрываемой аудиозаписи можно многое о ней сказать. Сразу определимся, что под спектром мы будем иметь в виду спектр “окна” — небольшого участка сигнала, длительностью порядка десятков миллисекунд, который проигрывается в данный момент. Для получения спектра мы будем использовать оконное БПФ, но об этом чуть позже. Так вот. Глядя на спектр, можно сходу заметить многие факты. Например, можно приблизительно определить качество записи, а так-же тип кодирования с потерями (CBR или VBR). Зачастую, понижение битрейта достигается путем уменьшения верхней границы передаваемых частот, и эта граница хорошо заметна на спектре. Граница может и плавать в зависимости от амплитуды сигнала — это указывает на сжатие с переменным битрейтом (VBR). На спектре также, в виде слабо изменяющихся пиков, хорошо будут заметны любые посторонние шумы, присутствующие в записи. Причем, эти шумы даже можно легко убрать, изменив спектр…


Но это не так важно. Нас скорее интересует, как можно спектр превратить в… Красивую, гм, картинку. Подходов тут можно придумывать сколько угодно, разной степени изящества и извращенности, мне пришло в голову следующее. Сначала, определимся, как вообще этот спектр будет выглядеть в нашей программе. На выходе из БПФ у нас будет массив комплексных чисел, размером равным длине окна, или количеству отсчетов, попавших в окно. Я взял длину окна равную 2048, что при частоте дискретизации 44100 Гц составит ≈46 мс сигнала. Длину равную степени двойки здесь целесообразно выбирать для того, чтобы удобно было делать БПФ. Комплексные числа в выходном массиве характеризуют соответствующие им частоты, причем в нашем случае первому числу будет соответствовать частота 44100 / 2048 ≈ 21.5 Гц, последнему — 44100 Гц. Частота (или гармоника) у нас характеризуется двумя вещами: амплитудой, и фазой. Амплитуду интересующей гармоники можно вычислить, взяв модуль соответствующего комплексного числа, а фаза соответствует его аргументу. Практика еще показывает, что амплитуды удобно рассматривать в логарифмическом диапазоне, или другими словами, сразу брать их логарифм, и работать с ним. График спектра, по крайней мере, получается в таком масштабе более, гм, приятным для глаз. Тут еще стоит вспомнить про теорему Котельникова, и подметить важный факт — нас интересуют только первые 1024 числа в массиве, так как вторая его часть является зеркальным отражением первой. Эта удивительная особенность связана с тем, что по теореме Котельникова мы не можем при частоте дискретизации f сохранить сигнал с частотой больше чем f / 2.


Ближе к делу


Вот наконец мы подобрались и к самой идее. Начнем все с того, что вычислим несколько начальных параметров: общая мощность сигнала, и если так можно выразиться, “доминирующую” частоту. Мощность сигнала можно посчитать просто сложив все амплитуды всех частот в спектре, а вот второй параметр поинтереснее. Тут можно много фантазировать, брать максимум по амплитуде, использовать сглаживание, вычислять производные амплитуды… Я остановился на варианте, отдаленно похожем на математическое ожидание, только вместо вероятностей используются веса, равные отношению амплитуды нужной частоты к общей мощности сигнала. Пожалуй, стоит показать немного кода:


Код: C++
#define FFT_WINDOW_SIZE 2048

const int16_t *samples = this->wab->GetDataBufferOffset(position);

//.....

for(int i = 1; i < FFT_WINDOW_SIZE; ++i){
	this->fft_in[i].re = (samples[i * 2] + samples[i * 2 + 1]) * this->window_func[i] * 0.5;
	this->fft_in[i].im = 0;
}

CCFFT::FFT(this->fft_in, this->fft_out, (int) log2(FFT_WINDOW_SIZE), false);

// collect amplitudes
for(int i = 0; i < FFT_WINDOW_SIZE; ++i){
	this->fft_amps[i] = this->fft_out[i].abs();
}

//....

double signal_power = 0;
double freq_avg = 0;

for(int i = 0; i < FFT_WINDOW_SIZE / 2; ++i){
	signal_power += this->fft_amps[i];
}

for(int i = 0; i < FFT_WINDOW_SIZE / 2; ++i){
	this->rainbow_weights[i] = this->fft_amps[i] / signal_power;
}

for(int i = 0; i < FFT_WINDOW_SIZE / 2; ++i){
	freq_avg += this->rainbow_weights[i] * i;
}

Здесь мы видим, как вначале извлекается нужный кусок проигрываемого буфера с помощью функции GetDataBufferOffset, затем из него формируются входные данные для БПФ, собственно выполняется БПФ, и наконец, считаются две нужные нам величины. Буфер у нас, как можно заметить, содержит стерео запись, поэтому мы без лишних рассуждений вычисляем среднее арифметическое двух сигналов, и умножаем полученное на оконную функцию (про это чуть позже). Вообще, этот буфер на самом деле является огрызком WAV файла, загруженного в память — да да, все очень брутально…


Что-же можно сделать с полученными двумя величинами? А вот что. Мощность сигнала можно использовать для яркости картинки, а доминирующую частоту использовать для выбора цвета, которым мы наше светопредставление будем закрашивать. причём цвет удобно выбирать с помощью HSV диапазона, а не RGB, благо в Qt эти диапазоны проще простого между собой конвертировать. К примеру, Hue выставляем как доминирующую частоту, помноженную на какую-нибудь константу, и взятую по модулю (модуль и константа на глаз, я подстроил так, чтобы когда играет Rammstein, доминировал красный цвет), для Value или Brightness ставим мощность сигнала, причем, если мощность превышает какой-то предел (который можно, кстати, вычислять динамически, в зависимости от средней мощности за какой-то период), то ставим Brightness в 100%, и начинаем экспоненциально уменьшать Saturation. Как-то так. Ну, на практике получилось чуть посложнее.


Edit Colors
Рис. №1222. MsPaint знает что такое HSV. Инфа 100%

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


Код: C++
QPainter enot(this->visio);
enot.fillRect(QRect(0, 0, 640, 320), QBrush(Qt::black, Qt::SolidPattern));

//.....

double level = pow(signal_power / 2.0e8, 1.0 / 5.0);
double sat = max(1 - level * 1.3, 0.05);
double vbt = min(level * 1.0, 1.0);

QColor blinke_top;

// DIE ROTE FARBE IST DER RAMMSTEIN, RAMMSTEIN, RAMMSTEIN!!11
double hue = fmod((freq_avg - 135.0) + 480.0, 480.0) / 480.0;

blinke_top.setHsvF(hue, 1.0, 1.0);

for(int i = 0; i < 80; ++i){
	blinke_top.setHsvF(hue, sat, max(0.0, pow(vbt, 1.0 / (1 + level / 2.0)) + (i & 1 ? 0.0 : -0.05)));

	int rowwe = i * 2;

	enot.setPen(QPen(blinke_top, 2));
	enot.drawLine(0, rowwe, 640, rowwe);
	enot.drawLine(0, 319 - rowwe, 640, 319 - rowwe);

	sat = pow(sat, pow(level, 0.8));
	vbt -= max((1.0 - level) / 10.0, 1.0 / 79.0);
	if(vbt < 0) break;
}


enot.setPen(QPen(QColor(20, 20, 30), 1));
for(int i = 1; i < 640; ++i){
	int ss = i - 1;
	int es = i;

	int amp1 = (int)((samples[ss * 4]     + samples[ss * 4 + 1])) / 250.0 / 2.0;
	int amp2 = (int)((samples[es * 4 + 2] + samples[es * 4 + 3])) / 250.0 / 2.0;

	enot.drawLine(ss, amp1 + 160, es, amp2 + 160);
}

blinke_top.setHsvF(hue, 1.0, 1.0);
enot.setPen(QPen(blinke_top, 1));

for(int i = 1; i < 640; ++i){

	int ss = i - 1;
	int es = i;

	int ids = ss / 640.0 * FFT_WINDOW_SIZE / 2;
	int ide = es / 640.0 * FFT_WINDOW_SIZE / 2;

	int amp1 = log(max(this->fft_amps[ids], 1.0)) * 19.0;
	int amp2 = log(max(this->fft_amps[ide], 1.0)) * 19.0;

	enot.drawLine(ss, 319 - amp1, es, 319 - amp2);
}

this->repaint();

Тут и видно, как можно применить две посчитанные нами величины, а так-же, как можно спектр просто нарисовать, ну и заодно, здесь просто рисуется осциллограмма сигнала. Получается недурно:


FX Player, как он есть
Рис. №1211. FX Player, как он есть

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


Без названия
Рис. №1212. Что-то очень электронное

Без названия
Рис. №1213. Более типичная картина

Без названия
Рис. №1214. Что-то ещё более электронное

Без названия
Рис. №1215. Сигнал чуток превысил норму. Бывает…

Без названия
Рис. №1216. Здесь явно играет Rammstein

Конечно, несколькими картинками всю полученную красоту передать не получится. Посему, в конце статьи будут ссылки, по которым можно скачать как рабочую сборку всего этого дела (под Windows), а также исходники, которые возможно даже скомпилируются. Ну и понятное дело, результат можно получить и лучше. Гораздо лучше…


Технические подробности


Думаю, что эта порция текста только для тех, кому интересно, как всё это работает изнутри. И я попробую это вкратце детально изложить. Для начала, собственно музыку проигрывает штатный компонент Qt — QMediaPlayer. Однако, по крайней мере на моих двух машинах у него обнаружились проблемы с поддержкой VBR кодированных mp3. При проигрывании таких файлов часто неправильно определялась длительность, и почти всегда сбивалась позиция, причём очень существенно. В итоге, чтобы заставить это нормально работать, все файлы открываемые плеером конвертируются в стерео WAV файлы, с фиксированными параметрами 44100 Гц / 16 бит, с помощью великого, могучего и опенсорсного FFmpeg. Которые затем и подсовываются в QMediaPlayer.


FFmpeg запущенный из командной строки
Рис. №1224. FFmpeg всё может конвертировать в WAV. Абсолютно всё.

Проблема здесь, правда, скорее не в компоненте плеера, а в кодеках в моей системе, но дела это сильно не меняет. Следующая задача была, как можно догадаться, вытянуть буфер, содержащий аудиоданные, проигрываемые в данный момент времени. Для этого, оказывается тоже есть штатный компонент Qt, именуемый QAudioProbe. Я его даже успел прикрутить к своему коду, как выяснилось, что он не работает не реализован под Шindoшs. Мои эмоции зашкалили, и мне как обычно пришлось городить костыли.


Впрочем, WAV файл имеет достаточно простую структуру, и написать решение, позволяющее извлекать аудиодорожки из WAV файла — дело не больше чем на полчасика. Написанием такого решения я, собственно, и занялся. Получилось вот что:


Код: C++
#pragma pack(push,1)
struct TWaveHeader {
    unsigned long dwRIFF;
    unsigned long size;
    unsigned long dwWAVE;
    unsigned long dwFMT;
    unsigned long WavSize;
    pcmwaveformat_tag format;
    unsigned long dwData;
    unsigned long WavDataSize;
};
#pragma pack(pop)

class CWAVBuffer {
private:

    uint8_t *buffer;

public:

    static void FFMPEGConvertToWav(const LPWSTR basedir, const LPWSTR from, const LPWSTR to);
    bool LoadWAVFile(LPWSTR path);

    const TWaveHeader *GetHeader() const;
    const int16_t *GetDataBuffer() const;
    const int16_t *GetDataBufferOffset(const __int64 &ms) const;

    CWAVBuffer();
    ~CWAVBuffer();
};

Реализация с некоторыми опущенными подробностями:


Код: C++
//....
const TWaveHeader *CWAVBuffer::GetHeader() const {
    return (TWaveHeader *) this->buffer;
}

const int16_t *CWAVBuffer::GetDataBuffer() const {
    return (int16_t *)(this->buffer + sizeof(TWaveHeader));
}

const int16_t *CWAVBuffer::GetDataBufferOffset(const __int64 &ms) const {

    const TWaveHeader *hd = this->GetHeader();
    double smp_offset = hd->format.wf.nSamplesPerSec / 1000.0 * hd->format.wf.nChannels;
    return (int16_t *)(this->buffer + sizeof(TWaveHeader)) + (int)(smp_offset * ms);
}

bool CWAVBuffer::LoadWAVFile(LPWSTR path){
    FILE *wav = _wfopen(path, L"r");;

    if(NULL != wav){

        int lange = filelength(fileno(wav));
        this->buffer = new uint8_t[lange];

        fread(this->buffer, lange, 1, wav);
        fclose(wav);
        return true;
    }else{
        return false;
    }
}
//....

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


Оконное БПФ


После того как мы аудиоданные получим, их надо обрабатывать, то есть, в нашем случае извлекать из них спектр. Для этого мы будем использовать оконное Быстрое Преобразование Фурье. Что такое БПФ, и как оно работает, я уже где-то расписывал, советую, однако, почитать. А чем оконное БПФ отличается от неоконного? В оконном БПФ, входные данные просто умножаются на так называемую оконную функцию, что позволяет значительно снизить искажения спектра, вносимые тем фактом, что спектр мы получаем не на бесконечных пределах, а на каком-то конечном участке, как-бы уже неявно умножая сигнал на оконную функцию, представляющую собой прямоугольник с высотой 1.0. А такая прямоугольная функция — не самая удачная в плане вносимых искажений, поэтому её компенсируют, домножая на менее шумные функции. Оконных функций довольно много, о них можно прочитать к примеру в Википедии. Я, кстати, решил использовать окно Блэкмана


\[ w(n)=a_0 - a_1 \cos \left ( \frac{2 \pi n}{N-1} \right) + a_2 \cos \left ( \frac{4 \pi n}{N-1} \right) \] \[ a_0=\frac{1-\alpha}{2};\quad\; a_1=\frac{1}{2};\quad\; a_2=\frac{\alpha}{2}\,\]
Окно Блэкмана

А мы снова покажем немного кода, делающего, собственно, БПФ, ну и заодно занимающегося оконными функциями. Конечно-же, с опущенными подробностями:


Код: C++
class CComplex {
public:
    double re;
    double im;

    __fastcall double abs();

    __fastcall CComplex(const double &rr, const double &ii);
    __fastcall CComplex(const CComplex &x);
    __fastcall CComplex();

    __fastcall CComplex & operator += (const CComplex &r);
    __fastcall CComplex & operator -= (const CComplex &r);
    __fastcall CComplex & operator *= (const CComplex &r);
    __fastcall CComplex & operator /= (const CComplex &r);

    __fastcall CComplex operator + (const CComplex &r);
    __fastcall CComplex operator - (const CComplex &r);
    __fastcall CComplex operator * (const CComplex &r);
    __fastcall CComplex operator / (const CComplex &r);
};


class CCFFT {
private:

    static int * brev_table[64];
    static CComplex* root_table[2][64];

public:

    static int * InitBRevTable(int lon2n);
    static CComplex * InitRootTable(bool inv, int lon2n);

    static void FFT(const CComplex *src, CComplex *dst, int log2n, bool inv);
    static void WindowBlackman(double *dst, int n, const double &alpha);
    static void WindowGauss(double *dst, int n, const double &scale, const double &sigma);

};

Можно было-бы теоретически использовать код, который можно найти по ссылке выше, но он СЛИШКОМ суровый, содержит летальные дозы SSE, и по вполне понятным причинам не компилируется GCC. Хотя и довольно быстро работает. Правда там ещё не учтен факт, что массив корней из единицы можно кэшировать, и там он каждый раз вычисляется заново. А здесь мы это учтем…


Код: C++
int *CCFFT::brev_table[64];
CComplex* CCFFT::root_table[2][64];

void CCFFT::FFT(const CComplex *src, CComplex *dst, int log2n, bool inv){
    int n = 1 << log2n;

    int *brev_row = InitBRevTable(log2n);

    for(int i = 0; i < n; ++i) dst[i] = src[i];
    for(int i = 0; i < n; ++i){
        if(i < brev_row[i]){
            swap(dst[i], dst[brev_row[i]]);
        }
    }

    CComplex u, v;

    for(int lpw = 1; lpw <= log2n; ++lpw){
        int l1 = 1 << lpw;
        int l2 = 1 << lpw - 1;

        CComplex *roots = InitRootTable(inv, lpw);

        for(int i = 0; i < n; i += l1){
            for(int j = 0; j < l2; ++j){
                u = dst[i + j];
                v = dst[i + j + l2] * roots[j];

                // butterfly transform
                dst[i + j] = u + v;
                dst[i + j + l2] = u - v;
            }
        }
    }
}

int * CCFFT::InitBRevTable(int log2n){
    if(NULL != CCFFT::brev_table[log2n]) return CCFFT::brev_table[log2n];

    int n = 1 << log2n;

    brev_table[log2n] = new int[n];

    for(int i = 0; i < n; ++i){
        brev_table[log2n][i] = 0;
        for(int j = 0; j < log2n; ++j){
            brev_table[log2n][i] |= (i >> log2n - j - 1 & 1) << j;
        }
    }

    return brev_table[log2n];
}

CComplex * CCFFT::InitRootTable(bool inv, int log2n){
    if(NULL != root_table[inv][log2n]) return root_table[inv][log2n];

    int n = 1 << log2n;

    root_table[inv][log2n] = new CComplex[n];

    for(int i = 0; i < n; ++i){
        double phase = 2.0 * M_PI / n * i * (inv ? -1 : 1);

        root_table[inv][log2n][i].re = cos(phase);
        root_table[inv][log2n][i].im = sin(phase);
    }

    return root_table[inv][log2n];
}

void CCFFT::WindowBlackman(double *dst, int n, const double &alpha){
    double a0 = (1.0 - alpha) / 2.0;
    double a1 = 0.5;
    double a2 = alpha / 2.0;

    double denom = n - 1;

    for(int i = 0; i < n; ++i){
        dst[i] =
            a0 -
            a1 * cos(2.0 * M_PI * i / denom) +
            a2 * cos(4.0 * M_PI * i / denom);
    }
}

Постойте, а что это ещё за CComplex? Ах да, совсем забыл. Всё от того, что я недостаточно верю в высокую производительность стандартной библиотеки люблю изобретать велосипеды, и поэтому класс для работы с комплексными числами написал руками. Впрочем, писать там особо нечего, да и есть мнение, что велосипеды действительно работают быстрее чем <complex>. Можете, впрочем, сами проверить. А вообще, код выше умеет делать БПФ, и оконную функцию, причем последнюю выдает в виде массива из чисел double. В БПФ, как можно заметить, лениво вычисляются и кэшируются векторы для поразрядных перестановок, и мнимых корней из единицы, с помощью соответствующих функций. Собственно говоря, это и всё, что нужно для обработки нашего сигнала. Все остальное можно выяснить, если покопать в исходниках, я думаю…


Исходники и рабочая сборка


Как обещалось выше, выкладываю немного файлов:


  • Скомпилированная версия плеера
  • Исходники в виде проекта Qt;


Вот, собственно, мы представили небольшую концепцию создания простого визуализатора на основе простых и понятных вещей. Напоследок в качестве дисклеймера надо сказать, что данная статья, и в частности, программа представленная в ней только демонстрируют концепцию, а никак не являются сколько-нибудь конечными продуктами. Ну, хотя я думаю это и так понятно :) Впрочем, не важно. Будем надеяться, что интересующиеся смогут извлечь из этого материала пользу. А вообще, до скорых встреч, и приятного залипания :).


Продолжительность 06:38    Битрейт 320 кбит/с   

Till Lindemann
Рис. №1218. Till Lindemann
Ключевые слова Программизм, MilkDrop, Qt, визуализатор, спектр, БПФ, WAV, HSV
QR-код
Интересное
Контроллер двигателя с КМОП логикой На повестку вечера встал вопрос о сборке устройства, которое будет отвечать за перезарядку оружия (мы же помним про полную автоматику, не так ли), то-есть, осуществлять подачу стальных гвоздей, гордо именуемых “снаряды”

Читать »»
Случайные фото