如何在Karplus-Strong算法中实现插值延迟线和全通滤波器?

4
好的,我已经用C实现了Karplus-Strong算法。这是一种简单的算法,用于模拟弹奏弦乐器的声音。您需要一个长度为n(n=采样频率/所需频率)的环形缓冲区,通过简单的两点平均滤波器将其传递 y[n] = (x[n] + x[n-1])/2,输出它,然后将其反馈到延迟线中。反复进行此操作。这样随着时间的推移可以平滑噪音,从而创建出自然的弹奏弦乐器的声音。
但我发现,使用整数延迟线长度时,几个高音可能会匹配到相同的延迟长度。此外,整数延迟长度不允许平滑变化的音高(例如颤音或滑音)。我已经阅读了几篇关于Karplus算法扩展的论文,它们都谈到使用插值延迟线来进行分数延迟或使用全通滤波器。

http://quod.lib.umich.edu/cgi/p/pod/dod-idx?c=icmc;idno=bbp2372.1997.068
http://www.jaffe.com/Jaffe-Smith-Extensions-CMJ-1983.pdf
http://www.music.mcgill.ca/~gary/courses/projects/618_2009/NickDonaldson/index.html

我之前实现过插值延迟线,但只是在波表上,其中波形缓冲区不会改变。我只是以不同的速率遍历延迟。但让我困惑的是,当涉及到KS算法时,论文似乎在谈论实际改变延迟长度而不仅仅是我遍历它的速率。KS算法使事情变得复杂,因为我应该不断将值反馈到延迟线中。
那么我该如何实现这个?我将插值值反馈回去吗?我完全取消两点平均低通滤波器吗?
所有通滤波器如何工作?我是否应该用所有通滤波器替换2点平均滤波器?我该如何使用线性插值方法或所有通滤波器方法在不同音高间进行滑音?
2个回答

2
数字信号处理算法通常以块图表示,这是一个很好的思考方式。在编码时,将每个块视为具有固定输入和输出的单独单元。我认为你的一些问题来自于试图过早地组合系统的各个元素。
以下是Karplus Strong的块图。
对于延迟块,您需要实现分数延迟线。这将包括其自己的低通滤波器,但这是延迟线实现的细节。Karplus Strong效果还需要低通滤波器。这些滤波器的特性将不同。不要尝试组合。顺便说一句,您选择的平均低通滤波器具有较差的频率响应,会引入“梳状滤波器”效应。您可能需要设计更复杂的FIR或IIR滤波器。
“那么我该如何实现呢?我要将插值值反馈进去吗?我完全取消两点平均低通滤波器吗?”
您确实需要将插值后的总和样本反馈到延迟线中,就像块图所示。在某些情况下,这可能会开始增加系统的净增益,如果您担心这一点,您可能需要“归一化”延迟的输出,以使其不失控。
有许多有效的策略可用于实现分数延迟线,包括您提到的插值和全通滤波器。想法是您将希望维护对延迟线的“读”和“写”索引。延迟线的长度不是内存缓冲区的总长度,而是索引之间的差异对延迟线的总长度取模。使延迟线足够大,并且不要担心调整大小。
我发现将读和写视为自由运行计数器最方便,它们永远不会环绕或过期,因为这样
current_delay_length = (write - read) % total_delay_length
current_read_sample = delay_line[read % total_delay_length]

其中%表示取模。如果写入和读取计数器是浮点值或设置为定点值,它们也可以包含小数长度。无论哪种情况,这都使得修改延迟线的长度变得容易。重要的是要确保强制执行最小延迟(写入>读取)。

信不信由你,通过改变你在延迟线中步进的速率,就像固定长度缓冲区一样,你将会改变延迟线的长度。通常你会稍微调制读取索引。它不应该落后于写指针一个缓冲区长度以上,也不应该超过它,否则会出现故障。但你可以自由地将读指针移动到写指针之后的任何位置。改变调制将获得不同的效果。

我强调,如滑音等效果来自于如何操作延迟线的读取和写入索引,而不是如何实现它。您将从全通滤波器或线性插值延迟线中获得类似的声音。更好的分数延迟线将减少混叠噪声并支持更快速的读取指针变化,例如。


2

我实现了三种变体,它们都有优点和缺点,但没有一种是完美的,就像我希望的那样。也许有人有更好的算法,并想在这里分享一下?

总的来说,我像jbarlow描述的那样做。我使用2^x的环形缓冲区长度,其中x足够大,例如12,这将意味着最大延迟长度为2^12=4096个样本,如果以48kHz渲染,则为约12Hz的最低基频率。 二次幂的原因是模数可以通过按位AND来完成,这比实际模数要便宜得多。

// init
int writepointer = 0;

// loop:
writepointer = (writepointer+1) & 0xFFF;

写指针保持简单,例如从0开始,并且每个输出样本始终增加1。
读指针从与写指针的增量开始,每次频率更改时都会重新计算。
// init
float delta = samplingrate/frequency;
int readpointer = (writepointer-(int)delta)-1) & 0xFFF;
float frac = delta-(int)delta;
weight_a = frac;
weight_b = (1.0-frac);

// loop:
readpointer = (readpointer + 1) & 0xFFF;

它也递增1,但通常位于两个整数位置之间。我们使用向下取整的位置存储整数读指针。这和下一个样本之间的权重是weight_a和_b。

变化#1: 忽略小数部分,按原样处理(整数)读指针。

优点:无副作用,完美延迟(由于延迟没有隐式低通效应,意味着对频率响应有完全控制,没有伪像)

缺点:基础频率大多数情况下稍微偏离,量化为整数位置。这对高音符号听起来非常走调,不能进行微妙的音高变化。

变化#2: 在读指针样本和下一个样本之间进行线性插值。 这意味着我实际上从环形缓冲区中读取了两个连续的样本,并将它们加权相加,分别加上weight_a和weight_b。

优点:完美的基础频率,没有伪像

缺点:线性插值引入了一个可能不需要的低通滤波器。更糟糕的是,低通变化取决于音高。如果小数部分接近0或1,则只有很少的低通滤波,而小数部分约为0.5则进行了重低通滤波。这使得乐器的某些音符比其他音符更明亮,并且它永远不能比这个低通允许的更明亮。(对于钢琴或古钢琴来说很糟糕)
变化#3:有点抖动。我总是从整数位置读取延迟,但跟踪我所做的错误,意味着有一个变量将小数部分相加。一旦超过1,我就从误差中减去1.0,并从第二个位置读取延迟。
优点:完美的基频,没有隐含的低通
缺点:引入可听见的伪像,使其听起来像低保真度的声音。(如最近邻下采样)。
结论:没有一个变化是令人满意的。要么你无法拥有正确的音高,中性的频率响应,要么你会引入伪像。
我在文献中读到,全通滤波器应该做得更好,但是延迟线不是已经是全通了吗?在实现上有什么区别?

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接