起因

这是车载 HIFI 播放器开发系列的一篇:整机控制逻辑用 Go 写、跑在车载 Linux 核心板上,需要程序化地播放本地音频。Go 生态里选了 faiface/beep——它把"解码"和"输出"分层抽象得很干净,四种格式一套接口。

使用的库:https://github.com/faiface/beep(可解析播放 WAV, MP3, OGG 和 FLAC)

需求

  • Go 程序直接播放 WAV / MP3 / FLAC / OGG 文件
  • 后续能响应停止、切歌等控制信号(车载场景)

技术实现原理

播放音频 = 解码 + 输出,beep 的分层

音频播放要干两件事:解码(把 MP3/FLAC 这类压缩格式还原成 PCM 采样)和输出(把 PCM 按采样率节拍喂给声卡)。beep 就是按这两层设计的:

  • 解码层:mp3wavflacvorbis 各一个包,Decode(io.Reader) 都返回统一的 beep.Streamer 接口——一条 float64 采样流。接口统一,换格式只是换一个 Decode 调用;
  • 输出层:speaker 包基于 oto 库绑定各平台的系统音频 API(macOS 是 OpenAL,Linux 是 ALSA),后台 goroutine 按 Init 时定的采样率消费 Streamer,喂给声卡。

speaker.Init 的两个参数

  • 第一个参数是采样率,必须和音频文件一致:声卡播放时钟按它走,文件是 44100 却 Init 成 48000,出来的就是变调变速;
  • 第二个参数是缓冲大小,format.SampleRate.N(time.Second/10) 的含义是"1/10 秒能装多少个采样",即 100ms 缓冲。这是延迟与稳定性的平衡:缓冲太小,后台取数跟不上就断音、CPU 占用高;太大,停止/切歌的响应就慢。车载这种有频繁控制的场景,这个值值得单独调。

为什么 speaker.Play 之后要 select{} 阻塞

speaker.Play(streamer) 只是把 streamer 挂进混音器就立即返回,声音在后台 goroutine 里继续放。主 goroutine 一旦跑完退出,整个进程结束——音乐还没出声就没了。所以测试代码用 select {} 把主 goroutine 永久挂起。

这也是为什么实际工程里换成 for + select 监听控制 channel:既保住主 goroutine 不退出,又能随时响应停止/切歌指令。

代码实现

参考官方的Wiki文档可以写出如下测试文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package musicplayer
 
import (
	"fmt"
	"github.com/faiface/beep/mp3"
	"github.com/faiface/beep/speaker"
	"log"
	"os"
	"time"
)
 
type MusicEntry struct {
	Id         string   //编号
	Name       string   //歌名
	Artist     string   //作者
	Source     string   //位置
	Type       string   //类型
	Filestream *os.File // 文件流
}
 
func (me *MusicEntry) Open() {
	var err error
	me.Filestream, err = os.Open(me.Source)
	if err != nil {
		log.Fatal(err)
	}
}
 
func (me *MusicEntry) Play() {
	streamer, format, err := mp3.Decode(me.Filestream)
	if err != nil {
		log.Fatal(err)
	}
	defer streamer.Close()
 
	speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10))
	fmt.Println("music length :", streamer.Len())
	speaker.Play(streamer)
	select {}
}

代码说明

  • Open() 只负责拿到文件句柄;Play()mp3.Decode 把文件流解码成 Streamer;
  • speaker.Init(format.SampleRate, ...) 用解码得到的真实采样率初始化输出;streamer.Len() 返回总采样数,除以采样率就是时长;
  • select {} 阻塞主 goroutine(原理见上)。如果要实现停止操作,则使用 for 语句再配合 select 对通信信号进行监听就好了。

环境与编译警告

我这里使用的Go语言版本为1.13.8,操作系统为macOS Catalina,所以在编译过程中会出现如下警告,但不影响使用。

警告的原理:oto 在 macOS 上的后端是 OpenAL,而 Apple 从 macOS 10.15 起把 OpenAL 标记为 deprecated(官方主推 AVAudioEngine 替代),cgo 编译这些系统调用时就带出了 -Wdeprecated-declarations 警告。它只是提示 API 将来会被移除,不影响运行;而且部署目标是车载 Linux(走 ALSA 后端),完全不经过 OpenAL:

1
2
3
4
# github.com/hajimehoshi/oto
../../go/pkg/mod/github.com/hajimehoshi/oto@v0.3.1/driver_openal.go:35:22: warning: 'alcOpenDevice' is deprecated: first deprecated in macOS 10.15 - OpenAL is deprecated in favor of AVAudioEngine [-Wdeprecated-declarations]
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/OpenAL.framework/Headers/alc.h:209:38: note: 'alcOpenDevice' has been explicitly marked deprecated here
../../go/pkg/mod/github.com/hajimehoshi/oto@v0.3.1/driver_openal.go:39:11: warning: 'alcCloseDevice' is deprecated: first deprecated in macOS 10.15 - OpenAL is deprecated in favor of AVAudioEngine [-Wdeprecated-declarations]

踩坑记录与注意事项

  • 采样率不匹配会变调变速:44100 的文件按 48000 播,节拍和音高都不对。多首不同采样率的歌连播,用 beep.Resample 统一重采样到 speaker.Init 的采样率,而不是反复 Init;
  • log.Fatal 会直接 os.Exit(1):defer 都不执行,工程代码里换成返回 error 由上层决定;
  • defer streamer.Close() 在 select{} 永久阻塞期间不会执行:切歌场景要在 Play 下一首之前显式 Close 上一首的 streamer,否则文件句柄慢慢泄漏;
  • 缓冲大小:100ms 是稳妥起点,断音就调大、要快响应就调小;
  • 交叉编译到嵌入式 Linux:oto 依赖 cgo 和 alsa-lib,要 CGO_ENABLED=1 并配好目标板的交叉工具链,直接 GOOS=linux go build 会失败。