使用Go播放音频:波形表

原文地址: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
}

正如你所见,大多数代码是对我们之前编写的内容的扩展。