一、项目背景与目标
音乐投屏,说白了就是把手机上的音乐“扔”到车机上去播放。你想想看,手机里存了那么多歌,各种歌单、会员账号都在手机上,开车时却要用车机那套不太智能的音乐系统,多别扭。
这个项目我们要做的,就是让手机端的音乐数据流通过我们自定义的协议传输到车机端,车机负责解码播放,同时手机端还能远程控制——切歌、暂停、调节音量,全都能搞定。
我记得第一次做这个功能时,踩了不少坑。最典型的就是音频同步问题——手机点了暂停,车机那边延迟了两三秒才停。用户都切歌了,车机还在唱上一首。嗯,这种体验肯定不行。所以这次我们直接从架构层面把同步机制设计好。
二、整体架构设计
先看整体架构,我习惯把系统分成三层:
- 手机端(发送端):负责音频数据采集、编码、发送,以及接收控制指令
- 车机端(接收端):负责音频数据接收、解码、播放,以及发送控制指令
- 通信层:基于Android Automotive的Car API + 自定义Socket通道
这里有个关键点:音频数据流和控制指令流必须分离。为什么?因为控制指令需要低延迟、高可靠性,而音频流可以容忍一定的延迟和丢包。混在一起的话,控制指令会被音频数据堵住。
核心设计原则:音频通道用UDP(容忍丢包),控制通道用TCP(保证可靠)。
三、核心协议设计
协议这块,我直接说结论:别自己造轮子,但也不能完全用现成的。我们基于WebSocket做控制通道,自定义一个轻量级的二进制协议做音频流。
3.1 控制协议格式
控制指令我用JSON,简单明了。每个指令包含三个字段:
{
"cmd": "PLAY" | "PAUSE" | "NEXT" | "PREV" | "SEEK" | "VOLUME",
"params": {
"position": 12345, // 播放位置(毫秒)
"volume": 0.8 // 音量(0.0 ~ 1.0)
},
"timestamp": 1688888888 // 时间戳,用于同步
}
小技巧:时间戳字段很重要。我遇到过手机和车机时间不同步导致控制延迟的问题。后来加上时间戳,车机端根据差值做补偿,延迟从2秒降到了200毫秒以内。
3.2 音频数据协议
音频数据我用固定长度的帧结构:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头标识 | 2 | 0xAA55,用于校验 |
| 序列号 | 4 | 递增,用于检测丢包 |
| 音频数据长度 | 2 | 最大1024字节 |
| 音频数据 | 可变 | AAC或OPUS编码数据 |
| 校验和 | 2 | CRC16 |
为什么用固定帧头?因为UDP是面向报文的,你收到的可能是一个完整的帧,也可能是半个帧。帧头标识能帮你快速找到帧的起始位置。我曾经在这个问题上折腾了两天,后来老老实实加了帧头校验。
四、手机端实现要点
4.1 音频采集与编码
手机端我用MediaCodec做编码,支持AAC和OPUS两种格式。AAC兼容性好,OPUS在低码率下音质更好。我建议默认用AAC,如果网络状况差再切OPUS。
// 音频编码器配置
MediaFormat format = MediaFormat.createAudioFormat(
MimeType.AUDIO_AAC,
44100, // 采样率
2 // 声道数
);
format.setInteger(MediaFormat.KEY_BIT_RATE, 128000);
format.setInteger(MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC);
MediaCodec encoder = MediaCodec.createEncoderByType(MimeType.AUDIO_AAC);
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encoder.start();
注意:编码器的输出是原始AAC数据,需要加上ADTS头才能被车机端正确解码。我刚开始忘了加,车机那边一直出杂音,排查了半天才发现是这个问题。
4.2 双通道管理
我写了一个DualChannelManager类,统一管理UDP和TCP两个通道:
public class DualChannelManager {
private DatagramSocket udpSocket; // 音频通道
private Socket tcpSocket; // 控制通道
public void sendAudioFrame(byte[] data) {
// UDP发送,不保证可靠
DatagramPacket packet = new DatagramPacket(
data, data.length, carIp, AUDIO_PORT
);
udpSocket.send(packet);
}
public void sendCommand(JSONObject cmd) {
// TCP发送,保证可靠
OutputStream os = tcpSocket.getOutputStream();
os.write(cmd.toString().getBytes());
os.flush();
}
}
五、车机端实现要点
5.1 音频解码与播放
车机端用AudioTrack播放音频。这里有个坑:AudioTrack的缓冲区大小要设置合理。太小了容易卡顿,太大了延迟高。我一般设置为音频帧的3倍大小。
// AudioTrack配置
int bufferSize = AudioTrack.getMinBufferSize(
44100,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT
) * 3; // 3倍缓冲区
AudioTrack audioTrack = new AudioTrack(
AudioManager.STREAM_MUSIC,
44100,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize,
AudioTrack.MODE_STREAM
);
audioTrack.play();
5.2 控制指令处理
控制指令的处理要快,不能阻塞音频播放线程。我单独开了一个HandlerThread来处理:
HandlerThread controlThread = new HandlerThread("ControlHandler");
controlThread.start();
Handler controlHandler = new Handler(controlThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case CMD_PLAY:
audioTrack.play();
break;
case CMD_PAUSE:
audioTrack.pause();
break;
case CMD_SEEK:
// 这里需要配合解码器做seek
decoder.seekTo((long) msg.obj);
break;
}
}
};
六、同步机制
同步是音乐投屏最头疼的问题。我用了两种策略:
- 时间戳同步:每个控制指令和音频帧都带时间戳,车机端根据时间戳调整播放进度
- 心跳包:每500ms发送一次心跳,包含当前播放位置,用于双方对齐
经验之谈:不要试图做到100%同步,那是不可能的。用户能接受的延迟范围是200-500ms。我们只要保证在这个范围内,用户就感觉不到延迟。
七、避坑指南
最后分享几个我踩过的坑:
- 音频格式不匹配:手机端编码的AAC格式,车机端解码器不一定支持。建议两端都用MediaCodec,保证兼容性。
- 网络切换:手机从WiFi切到移动网络时,UDP连接会断开。需要监听网络变化,自动重连。
- 车机休眠:车机息屏后,CPU可能会降频,导致音频解码跟不上。需要申请CPU唤醒锁。
- 权限问题:Android 12以上需要动态申请
BLUETOOTH_CONNECT权限,否则无法发现车机。
嗯,以上就是音乐投屏项目的核心内容。代码量其实不大,但细节很多。你按照这个思路去实现,基本不会出大问题。如果遇到具体问题,欢迎随时交流。