使用Go播放音频:断点
原文地址:https://dylanmeeus.github.io/posts/audio-from-scratch-pt5/
音频断点
为了清除潜在的错误,此博客文章是关于音频文件的断点,而不是我们用于调试代码的断点;-)。断点文件构成了DAWs中通常称为“ envelopes”或“automation tracks”的基础。
它们是包含timestamp:value
数据对的简单文件。通过这种简单的结构,它们使我们可以指定声音文件在某些时间点应具有的某些属性。
例如,下面是我在FL Studio中制作的一个自动化轨道的屏幕截图,由Toto制作:
image.png
在这里您可以比较两个声音文件(使用耳机更明显):
在这篇文章中,我们将仔细讨论断点文件的原理。
例如,可以将pan编码为:
时间(秒) | 值 |
---|---|
0 | 1 |
5 | -1 |
10 | 0 |
13.37 | 0.55 |
当第一个值编码时间时,第一个值必须是严格上升的值。但是,第二个值取决于你究竟要“自动化”什么。在我们的左右音频调节的情况下,我们可以使用介于-1到1之间的值对其进行编码。正如我们在上一篇文章中看到的那样,这将使我们能够以合适的方式修改样本以获得这种效果。但是请注意,我们的音频平移功能还不是完美的-因此我们不会获得与FL Studio完全相同的结果。以后再说。
线性插值
你可能已经注意到,断点文件中的时间值不必与样本相同地增加时间。这样非常好,因为我们可以缩小文件的大小,但是我们不希望音频在某个特定的时刻从左向右“跳跃”。如果我们对值进行编码:
时间(秒) | 值 |
---|---|
0 | 1 |
5 | -1 |
我们并不是说“从1开始,在第二个5处跳到-1”。我们实际上要说的是“从1开始,逐渐减小到-1”。解决这个问题的工具是线性插值。
因此,现在只需找出断点文件中我们正在处理的样本中的两个值。因此,如果我们的样本在歌曲中播放了2.5
秒钟,我们发现该值必须介于第二0
和第二个5
之间的一半,从而使我们有一个声像0
(左右音频完全平衡)。这些值0
,5
我们将称为 span
。
秘诀
(照常,所有代码都可以在Github上找到。
有了断点文件的背景信息,我们还牢记如何处理断点文件的方法。我们的断点模块需要执行几个步骤:
- 读取断点文件
- 将字符串解析为“时间-值”对
- 给定一帧时间,找到它之间的跨度
- 使用线性插值法查找确切值
会有一些边缘情况需要处理,但这只是我们所需要的粗略概述。
解析断点文件
我们应该实现的第一个功能是将实际文件解析为断点的功能。我们假设断点将作为文件传递,但实际上我们将对此进行抽象,而只接受一个io.Reader
。
我们的断点时间值参数将编码为time:value
。尽管可以根据需要使用其他分隔符,但这只是对代码的微小调整。
首先,我们可以定义断点类型:
type Breakpoint struct {
Time, Value float64
}
因此,我们从io.Reader中获取一些输入,然后将其解析为单独的行。然后,对于每一行,我们在分隔符(:)上分割并将这些值转换为float64值。这些被捆绑到我们的Breakpoint
结构体上,然后添加到一个[]Breakpoint
切片中,我们将返回给用户。这段代码可以使用一些切片边界检查,但是通过这种方式,本文更容易阅读
func ParseBreakpoints(in io.Reader) ([]Breakpoint, error) {
data, err := ioutil.ReadAll(in)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
brkpnts := []Breakpoint{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ":")
time := parts[0]
value := parts[1]
tf, err := strconv.ParseFloat(time, 64)
if err != nil {
return brkpnts, err
}
vf, err := strconv.ParseFloat(value, 64)
if err != nil {
return brkpnts, err
}
brkpnts = append(brkpnts, Breakpoint{
Time: tf,
Value: vf,
})
}
return brkpnts, nil
}
寻找正确的值
断点模块的另一个重要部分是在给定断点和请求时间的情况下实际返回一个值。如前所述,这将通过对给定范围内找到的值进行线性插值来完成。我们需要考虑的第一个边缘情况:如果我们的数据点位于最后一项会怎样?在这种情况下,不需要插值,我们只返回最后一个值。
第一步将是找到正确的跨度。我们可以遍历所有“时间-值”对,直到超出Time
部分为止,从而知道我们看到的最后一个时间是跨度的“开始”。
func ValueAt(bs []Breakpoint, time float64, startIndex int) (index int, value float64) {
if len(bs) == 0 {
return 0, 0
}
npoints := len(bs)
// first we need to find a span containing our timeslot
startSpan := startIndex // start of span
for _, b := range bs[startSpan:] {
if b.Time > time {
break
}
startSpan++
}
...
通过此代码,我们找到了正确的startSpan,并且在没有传递断点的情况下,我们还有一条小的保护语句。
可以在这里处理第一个边缘情况,如果我们startSpan
的片段大于切片中的断点数量,则可以返回循环中遇到的最后一个断点的值。
// Our span is never-ending (the last point in our breakpoint file was hit)
if startSpan == npoints {
return startSpan, bs[startSpan-1].Value
}
现在我们已经处理了这种边缘情况,实际上我们可以检索到一个跨度。我们碰到了第二个极端情况,如果断点的两次相同,该怎么办?想象一下:
时间(秒) | 值 |
---|---|
5 | 1 |
5 | -1 |
在这种情况下,我们必须返回与最后一个条目关联的值。因此,在我们的示例中,结果将为-1。如果用户希望在开始的5秒钟内逐渐将值增加到1,然后立即跳到-1。如果两个时间戳之间的“距离”为零,则可以检测到。
// check for instant jump
width := right.Time - left.Time
if width == 0 {
return startSpan, right.Value
}
最后,我们通过了“边缘情况”,并最终到达了插值部分。我们可以使用上面计算出的width
来完成此功能:
frac := (time - left.Time) / width
val := left.Value + ((right.Value - left.Value) * frac)
return startSpan, val
太好了,有了这个,我们所有人都将开始在我们的第一个自动化音轨上工作!