原文地址:https://dylanmeeus.github.io/posts/audio-from-scratch-pt9/
在上一篇文章中目标是合成不同的波形,例如三角波和方波。虽然此实现为我们提供了一个良好的开端,但它并没有我们想要的性能。所有这些波形都是周期性的,因此实际上并不需要始终计算。
当你不想一遍又一遍地重新计算内容时,解决方案是缓存,对于音频编程,我们将波形存储在“表”中,通过该表我们可以使用振荡器来查找值。通常,我们希望以选定的保真度存储一个波形周期,其中保真度/精度由我们存储的点的个数决定。
由于我们在较小的点上对波进行分块,因此我们可能无法在给定的时间戳上获取波的确切值。为了解决这个问题,我们将使用线性插值法来估计缺少的时间戳的值,类似于我们之前解决为断点找到正确值的方法(第五章) 。
建立波形表
从根本上讲,此问题有两个部分,首先,我们需要弄清楚如何将波形存储在表格中,其次,我们需要弄清楚如何以正确的频率从表格中读取数据。
为了存储波形,我们将要沿波存储X
数据点。这些数据点是我们正在抽取的样本。在模拟信号中,我们有一个连续波,当我们将其转换为数字信号时,它会变成离散信号,但对于足够大X
的信号,它将与真实信号变得非常接近。(这种等效性对我们帮助很大)。
在下图中,我们可以看到采样率如何影响信号抓取的快照数量。
采样正弦波如果我们采用一半的采样率,那么我们只能得到4个数据点。实际上,对于给定信号,我们可以使用的采样率是有限制的。这就是Nyquist Limit(奈奎斯特频率),现在我们只需要知道它的存在。
以一半的点采样为了弄清楚每个点之间的间距,我们可以使用step = (2*PI)/X
。一旦有了这个,我们就循环0 -> X
并产生期望值。对于正弦波,则变为:
type Gtable struct {
data []float64
}
func NewSineTable(length int) *Gtable {
g := &Gtable{}
if length == 0 {
return g
}
g.data = make([]float64, length+1) // one extra for the guard point.
step := tau / float64(Len(g))
for i := 0; i < Len(g); i++ {
g.data[i] = math.Sin(step * float64(i))
}
// store a guard point
g.data[len(g.data)-1] = g.data[0]
return g
}
最后一位,即表中的最后一个条目等于第一个条目,这将有助于我们在振荡器中进行线性插值。暂时不必为此担心。还请记住,在代码中,我们使用tau = 2 * PI
。
振荡器
存储数据是非常重要的一步,但是我们需要利用这些数据来获取声音。为此,我们将调整上一篇文章的振荡器。这段代码的大部分看起来应该很熟悉。
首先,我们需要调整振荡器,以便它可以存储对表格的引用,并且为了方便起见,还存储了“大小超过采样率”变量,这与我们之前的策略稍有不同。构造函数还需要进行一些改动。
type LookupOscillator struct {
Oscillator
Table *Gtable
SizeOverSr float64 // convenience variable for calculations
}
func NewLookupOscillator(sr int, t *Gtable, phase float64) (*LookupOscillator, error) {
if t == nil || len(t.data) == 0 {
return nil, errors.New("Invalid table provided for lookup oscillator")
}
return &LookupOscillator{
Oscillator: Oscillator{
curfreq: 0.0,
curphase: float64(Len(t)) * phase,
incr: 0.0,
},
Table: t,
SizeOverSr: float64(Len(t)) / float64(sr),
}, nil
}
实际上,这里的大部分内容保持不变。主要区别在于在振荡过程中如何实际检索下一个浮点值。当我们生成波形时,可能会发生未存储在表中的时间戳上请求数据的情况。在这一点上,我们必须使用线性插值来推断值,或者截断结果。
截断结果只是意味着我们接受我们的结果是不正确的,但我们接受的是失去一些精准度,而不是插入更接近真实的结果。不过,这不一定是一件坏事!如果我们的表包含足够的数据点,则每个数据点之间的差异将很小。因此,不会听到来自截断的效果。这是什么情况?老实说,我也不知道,但是测试起来会很有趣。:-)
由于实现起来很简单,所以我们从截断查找开始。请注意,我们还对请求的波形移动一定的频率。
func (l *LookupOscillator) TruncateTick(freq float64) []float64 {
index := l.curphase
if l.curfreq != freq {
l.curfreq = freq
l.incr = l.SizeOverSr * l.curfreq
}
curphase := l.curphase
curphase += l.incr
for curphase > float64(Len(l.Table)) {
curphase -= float64(Len(l.Table))
}
for curphase < 0.0 {
curphase += float64(Len(l.Table))
}
l.curphase = curphase
return l.Table.data[int(index)]
}
这与我们到目前为止所做的相当相似。每个周期,我们都会增加相位以产生波的下一部分。如果我们不在表的范围内,则我们将调整大小以再次位于范围之内。
截断发生在最后一行,我们为给定阶段找到的请求索引可能不是表中的索引。由于我们的指标是整数,而我们的阶段是浮点数,因此这种情况很可能经常发生。假设我们的相位值为“ 10.15”,在表中我们可以找到这些索引:
指数 | 值 |
---|---|
….. | ….. |
10 | 0.75 |
11 | 0.80 |
12 | 0.85 |
我们还做不到通过在索引10和11的值之间进行插值来找到大约0.15滴答的值0.75
,我们只是返回0.75
。在这里,每个索引将值增加0.05,这取决于我们在表中存储的点数。更多的点=较小的增量=截断时丢失的数据较少。
为了实现线性插值振荡器,我们可以应用与实现断点时相同的策略 。大多数振荡器代码保持不变,除了我们将查找所请求的相位位于两者之间的两个索引。
func (l *LookupOscillator) InterpolateTick(freq float64) float64 {
baseIndex := int(l.curphase)
nextIndex := baseIndex + 1
if l.curfreq != freq {
l.curfreq = freq
l.incr = l.SizeOverSr * l.curfreq
}
curphase := l.curphase
frac := curphase - float64(baseIndex)
val := l.Table.data[baseIndex]
slope := l.Table.data[nextIndex] - val
val += frac * slope
curphase += l.incr
for curphase > float64(Len(l.Table)) {
curphase -= float64(Len(l.Table))
}
for curphase < 0.0 {
curphase += float64(Len(l.Table))
}
l.curphase = curphase
return out
}
正如你所见,大多数代码是对我们之前编写的内容的扩展。
网友评论