Базовый программный синтезатор со временем увеличивает задержку

Я заканчиваю программный синтезатор с MIDI-управлением. Вход MIDI и синтез работают нормально, но у меня возникла проблема с воспроизведением самого звука.

Я использую jackd в качестве своего аудиосервера из-за возможности настроить его для приложений с низкой задержкой, например, в моем случае, для MIDI-инструментов в реальном времени, с alsa в качестве jackd бэкенда.

В своей программе я использую RtAudio, довольно известную библиотеку C++ для подключения к различным звуковым серверам и предлагающую базовые потоковые операции на них. Как следует из названия, он оптимизирован для звука в реальном времени.

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

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

Поскольку я планирую использовать это для игры «вживую», то есть у себя дома, меня не беспокоит игра с постоянно растущей задержкой между нажатиями клавиш и звуковой обратной связью, которую я слышу.

Я пытался полностью сократить базу кода, воспроизводящую проблему, и больше не могу ее сокращать.

#include <queue>
#include <array>
#include <iostream>
#include <thread>
#include <iomanip>
#include <Vc/Vc>
#include <RtAudio.h>
#include <chrono>
#include <ratio>
#include <algorithm>
#include <numeric>


float midi_to_note_freq(int note) {
    //Calculate difference in semitones to A4 (note number 69) and use equal temperament to find pitch.
    return 440 * std::pow(2, ((double)note - 69) / 12);
}


const unsigned short nh = 64; //number of harmonics the synthesizer will sum up to produce final wave

struct Synthesizer {
    using clock_t = std::chrono::high_resolution_clock;


    static std::chrono::time_point<clock_t> start_time;
    static std::array<unsigned char, 128> key_velocities;

    static std::chrono::time_point<clock_t> test_time;
    static std::array<float, nh> harmonics;

    static void init();
    static float get_sample();
};


std::array<float, nh> Synthesizer::harmonics = {0};
std::chrono::time_point<std::chrono::high_resolution_clock> Synthesizer::start_time, Synthesizer::test_time;
std::array<unsigned char, 128> Synthesizer::key_velocities = {0};


void Synthesizer::init() { 
    start_time = clock_t::now();
}

float Synthesizer::get_sample() {

    float t = std::chrono::duration_cast<std::chrono::duration<float, std::ratio<1,1>>> (clock_t::now() - start_time).count();

    Vc::float_v result = Vc::float_v::Zero();

    for (int i = 0; i<key_velocities.size(); i++) {
        if (key_velocities.at(i) == 0) continue;
        auto v = key_velocities[i];
        float f = midi_to_note_freq(i);
        int j = 0;
        for (;j + Vc::float_v::size() <= nh; j+=Vc::float_v::size()) {
            Vc::float_v twopift = Vc::float_v::generate([f,t,j](int n){return 2*3.14159268*(j+n+1)*f*t;});
            Vc::float_v harms = Vc::float_v::generate([harmonics, j](int n){return harmonics.at(n+j);});
            result += v*harms*Vc::sin(twopift); 
        }
    }
    return result.sum()/512;
}                                                                                                


std::queue<float> sample_buffer;

int streamCallback (void* output_buf, void* input_buf, unsigned int frame_count, double time_info, unsigned int stream_status, void* userData) {
    if(stream_status) std::cout << "Stream underflow" << std::endl;
    float* out = (float*) output_buf;
    for (int i = 0; i<frame_count; i++) {
        while(sample_buffer.empty()) {std::this_thread::sleep_for(std::chrono::nanoseconds(1000));}
        *out++ = sample_buffer.front(); 
        sample_buffer.pop();
    }
    return 0;
}


void get_samples(double ticks_per_second) {
    double tick_diff_ns = 1e9/ticks_per_second;
    double tolerance= 1/1000;

    auto clock_start = std::chrono::high_resolution_clock::now();
    auto next_tick = clock_start + std::chrono::duration<double, std::nano> (tick_diff_ns);
    while(true) {
        while(std::chrono::duration_cast<std::chrono::duration<double, std::nano>>(std::chrono::high_resolution_clock::now() - next_tick).count() < tolerance) {std::this_thread::sleep_for(std::chrono::nanoseconds(100));}
        sample_buffer.push(Synthesizer::get_sample());
        next_tick += std::chrono::duration<double, std::nano> (tick_diff_ns);
    }
}


int Vc_CDECL main(int argc, char** argv) {
    Synthesizer::init();

    /* Fill the harmonic amplitude array with amplitudes corresponding to a sawtooth wave, just for testing */
    std::generate(Synthesizer::harmonics.begin(), Synthesizer::harmonics.end(), [n=0]() mutable {
            n++;
            if (n%2 == 0) return -1/3.14159268/n;
            return 1/3.14159268/n;
        });

    RtAudio dac;

    RtAudio::StreamParameters params;
    params.deviceId = dac.getDefaultOutputDevice();
    params.nChannels = 1;
    params.firstChannel = 0;
    unsigned int buffer_length = 32;

    std::thread sample_processing_thread(get_samples, std::atoi(argv[1]));
    std::this_thread::sleep_for(std::chrono::milliseconds(10));

    dac.openStream(&params, nullptr, RTAUDIO_FLOAT32, std::atoi(argv[1]) /*sample rate*/, &buffer_length /*frames per buffer*/, streamCallback, nullptr /*data ptr*/);

    dac.startStream();

    bool noteOn = false;
    while(true) {
        noteOn = !noteOn;
        std::cout << "noteOn = " << std::boolalpha << noteOn << std::endl;
        Synthesizer::key_velocities.at(65) = noteOn*127;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    sample_processing_thread.join();
    dac.stopStream();
}

Для компиляции с g++ -march=native -pthread -o synth -Ofast main.cpp /usr/local/lib/libVc.a -lrtaudio

Программа ожидает частоту дискретизации в качестве первого аргумента. В моей настройке я использую jackd -P 99 -d alsa -p 256 -n 3 & в качестве звукового сервера (требуются права приоритета в реальном времени для текущего пользователя). Поскольку частота дискретизации по умолчанию для jackd составляет 48 кГц, я запускаю программу с ./synth 48000.

alsa можно использовать в качестве звукового сервера, хотя я предпочитаю использовать jackd, когда это возможно, по неясным причинам, включая взаимодействия pulseaudio и alsa.

Если вы вообще запустите программу, вы должны услышать, надеюсь, не слишком раздражающую пилообразную волну, играющую и не играющую через равные промежутки времени, с выводом консоли, когда игра должна начинаться и останавливаться. Когда noteOn установлено на true, синтезатор начинает производить пилообразную волну на любой частоте и останавливается, когда noteOn установлено на false.

Надеюсь, вы увидите, что сначала noteOn true и false почти идеально соответствуют воспроизведению и остановке звука, но мало-помалу источник звука начинает отставать, пока это не становится очень заметным примерно от 1 минуты до 1 минуты 30 секунд. моя машина.

Я на 99% уверен, что это не имеет никакого отношения к моей программе по следующим причинам.

«Аудио» проходит этот путь через программу.

  • Клавиша нажата.

  • Часы тикают на частоте 48 кГц в sample_processing_thread, вызывают Synthesizer::get_sample и передают вывод в std::queue, который позже используется в качестве буфера сэмплов.

  • Всякий раз, когда потоку RtAudio нужны сэмплы, он берет их из буфера сэмплов и перемещается дальше.

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

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

Таким образом, единственным возможным источником увеличения задержки будут внутренние функции RtAudio или самого звукового сервера. Я немного погуглил и не нашел ничего полезного.

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


Что я пробовал

  • Проверка часов на наличие какой-либо кумулятивной задержки: Суммарной задержки не обнаружено
  • Определение времени задержки между нажатиями клавиш и воспроизводимым первым образцом звука, чтобы увидеть, увеличивается ли эта задержка со временем: Задержка не увеличивается со временем
  • Определение времени задержки между потоком, запрашивающим образцы, и отправкой образцов в поток (начало и конец stream_callback): задержка не увеличивалась со временем

person ChemiCalChems    schedule 18.02.2018    source источник
comment
Пожалуйста, прокомментируйте, почему загадочное голосование против, и я отредактирую вопрос, чтобы, надеюсь, сделать его более разрешимым, чем сейчас.   -  person ChemiCalChems    schedule 18.02.2018


Ответы (1)


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

Простой способ исправить, удалить этот поток и очередь sample_buffer и сгенерировать образцы непосредственно в функции streamCallback.

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

  1. Замените очередь на достаточно небольшой круговой буфер фиксированной длины. Технически, std::queue тоже будет работать, просто медленнее, потому что он основан на указателях, и вам нужно вручную ограничить максимальный размер.

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

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

К сожалению, эффективная реализация этого довольно сложна. Вам нужна синхронизация для защиты общих данных, но вам не нужна слишком большая синхронизация, иначе производитель и потребитель будут сериализованы и будут использовать только один аппаратный поток. Один из подходов — это один std::mutex для защиты буфера при перемещении указателей/размера/смещения (но разблокировка при чтении/записи данных) и два std::condition_variable, один для спящего производителя, когда в буфере нет свободного места. буфер, еще один для сна, когда в буфере нет данных.

person Soonts    schedule 19.02.2018
comment
Это может быть так, однако возникает другая проблема дизайна. Я не могу просто генерировать звуковые образцы, когда захочу. Я хочу генерировать сэмплы с определенной частотой дискретизации, чтобы лучше представлять фактическую форму волны, которую я синтезирую, без искажений. Раньше я пытался генерировать сэмплы, когда это было необходимо, и это производило только непригодный для использования звук, который хуже, чем звук с растущей задержкой. - person ChemiCalChems; 19.02.2018
comment
Я думаю, моя точка зрения заключается в том, что я не читаю из статического звукового файла, я читаю текущее состояние моей клавиатуры. Я не могу просто заглянуть в будущее и сгенерировать, скажем, 256 выборок одновременно, потому что все они будут представлять примерно одно и то же значение выборки, поскольку все они были сгенерированы примерно в одно и то же время. - person ChemiCalChems; 19.02.2018
comment
@ChemiCalChems Частота дискретизации обычно фиксированная, обычно это 48 кГц для домашних пользователей, 96 или 192 кГц для профессионалов. - person Soonts; 19.02.2018
comment
Действительно, вы не можете заглянуть в будущее. Вот почему буферизация неизбежна. streamCallback не запрашивает у вас отдельные кадры, он просит вас предоставить frame_count кадров одновременно. И затем есть еще один уровень буферизации в ОС и оборудовании. У вас не может быть нулевой задержки, но вы можете иметь достаточно маленькую задержку для своего практического применения. - person Soonts; 19.02.2018
comment
Я знаю, что буферизация является необходимостью и что ожидается от stream_callback. Дело в том, что я не могу просто предоставить образцы, когда меня об этом попросят. Я предполагаю, что хорошая аналогия задается для текущей погоды и должна дать окончательное задание в конце недели. Вы должны отмечать текущую погоду через регулярные промежутки времени, чтобы наблюдать за изменениями погоды, вы не можете просто сказать ОН ДЕРЬМО за 5 минут до выполнения задания и сделать 50 журналов за 5 минут, потому что журналы не будут отражать погода на всю неделю. - person ChemiCalChems; 19.02.2018
comment
У меня просто возникла идея с моей аналогией. Погоду предсказать сложно, а выборочные значения — нет. Я мог бы воссоздать всю неделю погоды, когда меня попросили выполнить задание, вместо того, чтобы регулярно делать заметки. Это должно занять столько же времени, и мне вообще не нужно было бы запускать часы. Смотреть в прошлое легче, чем в настоящее. - person ChemiCalChems; 19.02.2018
comment
@ChemiCalChems Технически вы можете легко предоставить образцы, когда их попросят. Возьмите самую последнюю копию глобального состояния (например, набор воспроизводимых в данный момент MIDI-инструментов с высотой тона + скоростью нажатия) и используйте ее для генерации запрошенного количества сэмплов, предполагая, что все они в данный момент воспроизводятся. - person Soonts; 19.02.2018
comment
Практически это очень далеко не просто. C++ не имеет сопрограмм. Чтобы избежать артефактов, вам необходимо поддерживать информацию о фазе для каждого голоса, реализовывать надлежащий переход постепенного увеличения/уменьшения громкости для каждого голоса. Даже микширование голосов на удивление сложно, если вы хотите получить высококачественный результат и не хотите пропорционально уменьшать выходную громкость. - person Soonts; 19.02.2018
comment
В настоящее время я применяю эту идею. Я получаю некоторые легкие артефакты, но задержка, похоже, по большей части исчезла. - person ChemiCalChems; 19.02.2018
comment
Проблемы с задержкой полностью исчезли, теперь только уборка. Я знал, что второго мнения будет достаточно. Большое спасибо за это, я слишком долго выдергивал волосы из головы, теперь, наконец, я могу играть музыку без серьезных проблем с задержкой. Принятие ответа. - person ChemiCalChems; 19.02.2018