使用Go播放音频:自动恒定修正
原文地址:https://dylanmeeus.github.io/posts/audio-from-scratch-pt6/
在之前的文章中我们介绍了如何将单声道音频信号转换为立体声信号。该程序的一个缺点是无法在整个音轨中改变声场,这意味着一旦选择了声场,便会应用于整首歌曲。
现在,我们已经实现了断点,我们可以开始研究在整个轨道持续时间内改变声场(和其他属性)的情况。
在本文中,我们将获取单声道音频信号,然后将声场从左向右再次更改为向左。为了验证我们的代码是否按预期工作,最好使用干净的信号,因此,我们将再次使用mono sine.wav,持续时间为10秒。
断点文件
请记住,我们的断点文件格式包含成对的time:value
条目。让我们制作一个断点文件,该文件的声场从左到右,然后再向左,依此类推直至音轨结束。我们称这个为pan.brk
。
0:-1
2:1
4:-1
6:1
8:-1
10:1
因为这些值是插值的,所以在轨迹的整个过程中,我们会从左->右和从右->左逐渐偏移。
代码
该博客的所有代码都可以在Github上找到 。
幸运的是,我们已经编写了许多实现该程序的代码,大部分在 第4部分和第5部分中。这里唯一新增的部分是如何正确调用ValueAt
函数。
首先,我们需要添加一个指向断点文件的flag,因此我们的flag变为:
var (
input = flag.String("i", "", "input file")
output = flag.String("o", "", "output file")
brkpnt = flag.String("b", "", "breakpoint file")
)
接下来,我们需要创建一个将pan应用于断点的函数。此处的第一步是从断点文件中读取内容,并将其转换为Breakpoint structs
。在上一篇文章中,我们为此创建了ParseBreakpoints
函数。
在第4部分的同一文件中,我们可以添加此函数的开始。(为简便起见,我省略了错误处理)
func withBreakpointFile() {
flag.Parse()
file, _ := os.Open(*brkpnt)
pnts, _ := brk.ParseBreakpoints(file)
// read the input .wav file
infile := *input
wave, err := wav.ReadWaveFile(infile)
...
}
现在我们的设置完成了,我们在内存中有了断点和.wav,我们需要实际操作它们。我们在每帧找出正确声场的ValueAt
功能需要三个输入。([]Breakpoint, frameTime, offset)
。现在我们可以忽略offset,因此只有前两个很重要。我们的[]Breakpoint
可以从pnts
那里传递过来,因此我们剩下的就是弄清楚如何发送正确的frameTime
。
好了,要找出帧之间有多少时间,我们可以看一下SampleRate
读取.wav
输入文件时提取的时间。这样我们的时间增量就变成了1 / wave.SampleRate
。
当我们有时间增量时,剩下的就是迭代这些帧并使用正确的时间戳调用ValueAt
。将这些添加到我们的函数中,它将变为:
...
timeincr := 1.0 / float64(wave.SampleRate)
var frametime float64
inframes := wave.Frames
var out []wav.Frame
for _, s := range inframes {
// apply pan
_, pos := brk.ValueAt(pnts, frametime, 0)
pan := calculatePosition(pos)
out = append(out, wav.Frame(float64(s)*pan.left))
out = append(out, wav.Frame(float64(s)*pan.right))
frametime += timeincr
}
// write to stereo file
wave.WaveFmt.SetChannels(2)
wav.WriteFrames(out, wave.WaveFmt, *output)
}
我们现在可以通过运行以下命令进行测试:
go run main.go -i mono.wav -o stereo.wav -b pan.brk
我们可以得到以下结果:pan.wav
检查创建的声音。
如果你听了平移后的文件您可能已经注意到声音似乎有些偏离。事实证明,我们实现的pan功能过于简单,导致音量在中心(左右平衡)与完全左右平移时完全不同。
解决此问题的方法是实现“Constant Power Panning”功能。不管信号如何,这种功能确保总音量相似。
我们可以通过检查给定每个通道中不同幅度的信号功率来看到这一点。实现这个的方法是:sqrt(ampleft*ampleft + ampright*ampright)
。因此,对于我们位于中心的简单声场将变为:sqrt(0.5*0.5 + 0.5*0.5) = 0.707
这里的功率不等于1,但是当我们在左声道检查相同的功能时,我们注意到存在大约是3dB的损耗:
sqrt(1*1 + 0*0) = 1
因此,左/右的功率大于中间的功率。从根本上讲,这是我们的目的是修复这部分以获得恒定功率。
恒定功率平移
我将避免深入探讨技术细节,但是卡耐基梅隆大学的文章(Carnegie Mellon University)的这一页比我能更好地解释它。
我们当前的“simple panning”功能是线性平移,当我们查看每个通道中的音量时,我们会得到如下效果(也取自CMU)
image但是,在恒定功率平移的情况下,每个通道的音量效果会变为如下所示:
image你会注意到,尽管中心仍低于任一侧,但它的发音较不明显,并且下降速度较慢。
我们可以用来获得左右声道结果的函数是:
- 左:
sqrt(2)/2 * [cos(angle) + sin(angle)]
- 右:
sqrt(2)/2 * [cos(angle) - sing(angle)]
用代码实现此过程非常简单,但是我们也要记住,trig函数使用弧度,因此必须缩放我们的输入,以使每个通道映射到一个周期的1/4。
// calculateConstantPowerPosition finds the position of each speaker using a constant power function
func calculateConstantPowerPosition(position float64) panposition {
// half a sinusoid cycle
var halfpi float64 = math.Pi / 2
r := math.Sqrt(2.0) / 2
// scale position to fit in this range
scaled := position * halfpi
// each channel uses 1/4 of a cycle
angle := scaled / 2
pos := panposition{}
pos.left = r * (math.Cos(angle) - math.Sin(angle))
pos.right = r * (math.Cos(angle) + math.Sin(angle))
return pos
}
当我们用此函数替换simplePan
函数时,将得到以下输出:
当我们打开这些文件时,我们可以清楚地看到每个通道中幅度的上升和下降的差异。
image接下来,我们将研究如何从现有的声音文件中提取断点(只要它们是.WAV文件格式),并且还将讨论所使用的断点函数的性能。