基本软件合成器的延迟随着时间的推移而增长

Posted

技术标签:

【中文标题】基本软件合成器的延迟随着时间的推移而增长【英文标题】:Basic software synthesizer grows in latency over time 【发布时间】:2018-07-28 18:16:55 【问题描述】:

我正在完成一个 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 &amp; 作为我的声音服务器(需要当前用户的实时优先级权限)。由于jackd 的默认采样率为 48 kHz,我使用./synth 48000 运行程序。

alsa 可以用作声音服务器,但我更喜欢在可能的情况下使用jackd,原因包括pulseaudioalsa 交互。

如果您要运行该程序,您应该会听到一个希望不会太烦人的锯齿波播放并且不是定期播放,并且控制台输出在播放应该开始和停止时打开。当noteOn 设置为true 时,合成器开始以任意频率产生锯齿波,并在noteOn 设置为false 时停止。

您希望一开始会看到,noteOn truefalse 与音频播放和停止几乎完全对应,但是一点一点,音频源开始滞后,直到它开始变得非常明显在我的机器上大约 1 分钟到 1 分 30 秒。

我 99% 确定这与我的程序无关,原因如下。

“音频”在程序中采用这条路径。

按键被按下。

sample_processing_thread 中的时钟以 48 kHz 的频率滴答并调用 Synthesizer::get_sample 并将输出传递给 std::queue,该std::queue 用作稍后的样本缓冲区。

每当RtAudio 流需要样本时,它就会从样本缓冲区中获取样本并继续移动。

这里唯一可能导致延迟增加的原因是时钟滴答声,但它的滴答声与流消耗样本的速率相同,所以不可能。如果时钟滴答作响,RtAudio 会抱怨流欠载,并且会出现明显的音频损坏,这不会发生。

然而,时钟的点击速度可能会更快,但我认为并非如此,因为我已经在很多场合自己测试过时钟,虽然它确实显示出一点点抖动,以纳秒为单位,这是可以预料的。时钟本身没有累积延迟。

因此,延迟增长的唯一可能来源是RtAudio 的内部函数或声音服务器本身。我用谷歌搜索了一下,没有发现任何用处。

我已经尝试解决这个问题一两个星期了,并且我已经测试了我这边可能出现的所有问题,并且它按预期工作,所以我真的不知道会发生什么。


我的尝试

检查时钟是否有某种累积延迟:没有发现累积延迟 计时按键与生成的第一个音频样本之间的延迟,以查看此延迟是否随时间增加:延迟不随时间增加 计时请求样本的流与发送到流的样本之间的延迟(stream_callback 的开始和结束):延迟没有随时间增长

【问题讨论】:

请评论为什么神秘的否决票,我会编辑这个问题,希望它比现在更容易解决。 【参考方案1】:

我认为您的 get_samples 线程生成的音频比 streamCallback 消耗它们的速度更快或更慢。使用时钟进行流量控制是不可靠的。

修复、删除该线程和 sample_buffer 队列并直接在 streamCallback 函数中生成样本的简单方法。

如果您确实想为您的应用使用多线程,则需要在生产者和消费者之间进行适当的同步。复杂得多。但简而言之,步骤如下。

    用相当小的固定长度循环缓冲区替换您的队列。从技术上讲,std::queue 也可以工作,只是因为基于指针而速度较慢,并且您需要手动限制 max.size。

    在生产者线程中执行无限循环,检查缓冲区中是否有空白空间,如果有空间则生成更多音频,如果没有,则等待消费者从缓冲区中消费数据。

    李>

    在消费者 streamCallback 回调中,将数据从循环缓冲区复制到 output_buf。如果没有足够的数据可用,唤醒生产者线程并等待它产生数据。

不幸的是,要有效地实现这一点非常棘手。您需要同步来保护共享数据,但您不希望同步太多,否则生产者和消费者将被序列化并且将仅使用单个硬件线程。一种方法是单个 std::mutex 在移动指针/大小/偏移量时保护缓冲区(但在读取/写入数据时解锁),以及两个 std::condition_variable,一个用于在没有可用空间时让生产者休眠缓冲区,当缓冲区中没有数据时,另一个让消费者休眠。

【讨论】:

这可能是它,但是这会产生另一个设计问题。我不能随时简单地生成声音样本。我想以一定的采样率生成样本,以更好地代表我正在合成的实际波形而不会损坏。我之前尝试过在需要时生成样本,但它只会产生无法使用的音频,这比延迟越来越长的音频还要糟糕。 我想我的意思是我不是从静态声音文件中读取,而是“读取”键盘的当前状态。我不能简单地展望未来并一次生成 256 个样本,因为它们都代表大致相同的样本值,因为它们都是在大致相同的时间生成的。 @ChemiCalChems 采样率通常是固定的,家庭用户通常为 48 kHz,专业人士为 96 或 192 kHz。 确实你无法展望未来。这就是为什么缓冲是不可避免的。 streamCallback 不会要求您提供单个帧,它会要求您立即提供 frame_count 帧。然后,在操作系统和硬件中还有另一层缓冲。您不能实现零延迟,但可以为您的实际应用提供足够小的延迟。 延迟问题完全没有了,从现在开始只管家务。我知道第二个意见就足够了。非常感谢你,我已经把头发从头上拉出来太久了,现在我终于可以播放一些音乐而不会出现严重的延迟问题。接受答案。

以上是关于基本软件合成器的延迟随着时间的推移而增长的主要内容,如果未能解决你的问题,请参考以下文章

性能问题:查看时间负载随着时间的推移而增加

录制内部声音(来自软件合成器的输出)而不是来自麦克风

G1GC的延迟问题

iOS语音合成

合成/聚合复用原则,桥接模式

使用软件合成器将 MIDI 文件转换为原始音频