基本软件合成器的延迟会随着时间的推移而增加
Basic software synthesizer grows in latency over time
我正在完成一个 MIDI 控制的软件合成器。 MIDI 输入和合成工作正常,但我似乎在播放音频本身时遇到了问题。
我使用 jackd
作为我的音频服务器,因为可以为低延迟应用程序配置它,例如在我的情况下,实时 MIDI 乐器,使用 alsa
作为jackd
后端。
在我的程序中,我使用 RtAudio
这是一个相当著名的 C++ 库,用于连接到各种声音服务器并提供对它们的基本流操作。顾名思义,它针对实时音频进行了优化。
我还使用 Vc
库,这是一个为各种数学函数提供矢量化的库,以加快加法合成过程。我基本上是将大量不同频率和振幅的正弦波相加,以便在输出上产生复杂的波形,例如锯齿波或方波。
现在,问题不在于一开始的延迟很高,因为这可能会被解决或归咎于很多事情,例如 MIDI 输入或其他。问题是我的软合成器和最终音频输出之间的延迟开始时非常低,几分钟后,它变得高得无法忍受。
因为我打算用它来玩 "live",即在我家里,我真的懒得去玩,因为我的击键和我听到的音频反馈之间的延迟越来越长。
我一直试图减少重现问题的代码库,但我不能再减少了。
#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(¶ms, 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 kHz,因此我 运行 使用 ./synth 48000
的程序。
alsa
可以用作声音服务器,但我更喜欢尽可能使用 jackd
,原因不明,包括 pulseaudio
和 alsa
交互。
如果您完全进入 运行 程序,您应该听到希望不会太烦人的锯齿波定期播放和不播放,并在播放开始和停止时显示控制台输出。当 noteOn
设置为 true
时,合成器开始以任何频率产生锯齿波,并在 noteOn
设置为 false 时停止。
一开始你会看到,noteOn
true
和 false
几乎完美地对应了音频的播放和停止,但渐渐地,音频源开始滞后落后,直到它在我的机器上大约 1 分到 1 分 30 秒开始变得非常明显。
我 99% 确定它与我的程序无关,原因如下。
"audio" 通过程序采用此路径。
按键被按下
时钟在 sample_processing_thread
中以 48 kHz 滴答并调用 Synthesizer::get_sample
并将输出传递给 std::queue
用作稍后的样本缓冲区.
只要 RtAudio
流需要样本,它就会从样本缓冲区中获取样本并继续移动。
这里唯一可能导致延迟不断增加的原因是时钟滴答作响,但它以与流消耗样本相同的速率滴答作响,所以不可能是这样。如果时钟走得慢,RtAudio
会抱怨流低于 运行s,并且会出现明显的音频损坏,但这种情况不会发生。
虽然时钟可以点击得更快,但我认为情况并非如此,因为我已经多次自行测试时钟,虽然它确实显示出一点点抖动,但顺序如下纳秒,这是可以预料的。时钟本身没有累积延迟。
因此,延迟增加的唯一可能来源是 RtAudio
的内部功能或声音服务器本身。我已经 google 闲逛了一段时间,但没有找到任何用处。
我已经尝试解决这个问题一两周了,我已经测试了我这边所有可能出错的地方,它按预期工作,所以我真的不知道可能是什么发生。
我试过的
- 检查时钟是否有某种累积延迟:没有注意到累积延迟
- 计算按键与生成音频的第一个样本之间的延迟,以查看此延迟是否随时间增长:延迟不随时间增长
- 计算请求样本的流与发送到流的样本之间的延迟(
stream_callback
的开始和结束):延迟不随时间增长
我认为您的 get_samples 线程生成音频的速度比 streamCallback 消耗音频的速度快或慢。使用时钟进行流量控制是不可靠的。
修复、删除该线程和 sample_buffer 队列并直接在 streamCallback 函数中生成样本的简单方法。
如果您确实想为您的应用程序使用多线程,则需要在生产者和消费者之间进行适当的同步。复杂得多。不过简而言之,步骤如下。
用相当小的 fixed-length 循环缓冲区替换您的队列。从技术上讲,std::queue 也可以,只是速度较慢,因为 pointer-based,您需要手动限制 max.size.
在生产者线程中实现无限循环检查缓冲区中是否有空 space,如果有 space 则生成更多音频,如果没有,则等待消费者消费来自缓冲区的数据。
在consumer streamCallback回调中,将循环缓冲区中的数据复制到output_buf。如果没有足够的可用数据,则唤醒生产者线程并等待它生产数据。
不幸的是,要有效地实现它是非常棘手的。您需要同步来保护共享数据,但您不希望同步太多,否则生产者和消费者将被序列化,并且只会使用单个硬件线程。一种方法是单个 std::mutex 在移动 pointers/size/ofset 时保护缓冲区(但在 reading/writing 数据时解锁),以及两个 std::condition_variable,一个用于生产者在没有数据时休眠缓冲区中空闲 space,当缓冲区中没有数据时,另一个供消费者休眠。
我正在完成一个 MIDI 控制的软件合成器。 MIDI 输入和合成工作正常,但我似乎在播放音频本身时遇到了问题。
我使用 jackd
作为我的音频服务器,因为可以为低延迟应用程序配置它,例如在我的情况下,实时 MIDI 乐器,使用 alsa
作为jackd
后端。
在我的程序中,我使用 RtAudio
这是一个相当著名的 C++ 库,用于连接到各种声音服务器并提供对它们的基本流操作。顾名思义,它针对实时音频进行了优化。
我还使用 Vc
库,这是一个为各种数学函数提供矢量化的库,以加快加法合成过程。我基本上是将大量不同频率和振幅的正弦波相加,以便在输出上产生复杂的波形,例如锯齿波或方波。
现在,问题不在于一开始的延迟很高,因为这可能会被解决或归咎于很多事情,例如 MIDI 输入或其他。问题是我的软合成器和最终音频输出之间的延迟开始时非常低,几分钟后,它变得高得无法忍受。
因为我打算用它来玩 "live",即在我家里,我真的懒得去玩,因为我的击键和我听到的音频反馈之间的延迟越来越长。
我一直试图减少重现问题的代码库,但我不能再减少了。
#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(¶ms, 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 kHz,因此我 运行 使用 ./synth 48000
的程序。
alsa
可以用作声音服务器,但我更喜欢尽可能使用 jackd
,原因不明,包括 pulseaudio
和 alsa
交互。
如果您完全进入 运行 程序,您应该听到希望不会太烦人的锯齿波定期播放和不播放,并在播放开始和停止时显示控制台输出。当 noteOn
设置为 true
时,合成器开始以任何频率产生锯齿波,并在 noteOn
设置为 false 时停止。
一开始你会看到,noteOn
true
和 false
几乎完美地对应了音频的播放和停止,但渐渐地,音频源开始滞后落后,直到它在我的机器上大约 1 分到 1 分 30 秒开始变得非常明显。
我 99% 确定它与我的程序无关,原因如下。
"audio" 通过程序采用此路径。
按键被按下
时钟在
sample_processing_thread
中以 48 kHz 滴答并调用Synthesizer::get_sample
并将输出传递给std::queue
用作稍后的样本缓冲区.只要
RtAudio
流需要样本,它就会从样本缓冲区中获取样本并继续移动。
这里唯一可能导致延迟不断增加的原因是时钟滴答作响,但它以与流消耗样本相同的速率滴答作响,所以不可能是这样。如果时钟走得慢,RtAudio
会抱怨流低于 运行s,并且会出现明显的音频损坏,但这种情况不会发生。
虽然时钟可以点击得更快,但我认为情况并非如此,因为我已经多次自行测试时钟,虽然它确实显示出一点点抖动,但顺序如下纳秒,这是可以预料的。时钟本身没有累积延迟。
因此,延迟增加的唯一可能来源是 RtAudio
的内部功能或声音服务器本身。我已经 google 闲逛了一段时间,但没有找到任何用处。
我已经尝试解决这个问题一两周了,我已经测试了我这边所有可能出错的地方,它按预期工作,所以我真的不知道可能是什么发生。
我试过的
- 检查时钟是否有某种累积延迟:没有注意到累积延迟
- 计算按键与生成音频的第一个样本之间的延迟,以查看此延迟是否随时间增长:延迟不随时间增长
- 计算请求样本的流与发送到流的样本之间的延迟(
stream_callback
的开始和结束):延迟不随时间增长
我认为您的 get_samples 线程生成音频的速度比 streamCallback 消耗音频的速度快或慢。使用时钟进行流量控制是不可靠的。
修复、删除该线程和 sample_buffer 队列并直接在 streamCallback 函数中生成样本的简单方法。
如果您确实想为您的应用程序使用多线程,则需要在生产者和消费者之间进行适当的同步。复杂得多。不过简而言之,步骤如下。
用相当小的 fixed-length 循环缓冲区替换您的队列。从技术上讲,std::queue 也可以,只是速度较慢,因为 pointer-based,您需要手动限制 max.size.
在生产者线程中实现无限循环检查缓冲区中是否有空 space,如果有 space 则生成更多音频,如果没有,则等待消费者消费来自缓冲区的数据。
在consumer streamCallback回调中,将循环缓冲区中的数据复制到output_buf。如果没有足够的可用数据,则唤醒生产者线程并等待它生产数据。
不幸的是,要有效地实现它是非常棘手的。您需要同步来保护共享数据,但您不希望同步太多,否则生产者和消费者将被序列化,并且只会使用单个硬件线程。一种方法是单个 std::mutex 在移动 pointers/size/ofset 时保护缓冲区(但在 reading/writing 数据时解锁),以及两个 std::condition_variable,一个用于生产者在没有数据时休眠缓冲区中空闲 space,当缓冲区中没有数据时,另一个供消费者休眠。