一、项目背景与目标

音乐投屏,说白了就是把手机上的音乐“扔”到车机上去播放。你想想看,手机里存了那么多歌,各种歌单、会员账号都在手机上,开车时却要用车机那套不太智能的音乐系统,多别扭。

这个项目我们要做的,就是让手机端的音乐数据流通过我们自定义的协议传输到车机端,车机负责解码播放,同时手机端还能远程控制——切歌、暂停、调节音量,全都能搞定。

我记得第一次做这个功能时,踩了不少坑。最典型的就是音频同步问题——手机点了暂停,车机那边延迟了两三秒才停。用户都切歌了,车机还在唱上一首。嗯,这种体验肯定不行。所以这次我们直接从架构层面把同步机制设计好。

二、整体架构设计

先看整体架构,我习惯把系统分成三层:

  • 手机端(发送端):负责音频数据采集、编码、发送,以及接收控制指令
  • 车机端(接收端):负责音频数据接收、解码、播放,以及发送控制指令
  • 通信层:基于Android Automotive的Car API + 自定义Socket通道

这里有个关键点:音频数据流和控制指令流必须分离。为什么?因为控制指令需要低延迟、高可靠性,而音频流可以容忍一定的延迟和丢包。混在一起的话,控制指令会被音频数据堵住。

核心设计原则:音频通道用UDP(容忍丢包),控制通道用TCP(保证可靠)。

手机端(发送端) 音频采集模块 音频编码器(AAC/OPUS) 控制指令发送器 UDP/TCP 双通道管理 车机端(接收端) 音频解码器 音频播放器(AudioTrack) 控制指令接收器 UDP/TCP 双通道管理 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权限,否则无法发现车机。

嗯,以上就是音乐投屏项目的核心内容。代码量其实不大,但细节很多。你按照这个思路去实现,基本不会出大问题。如果遇到具体问题,欢迎随时交流。

公众号:蓝海资料掘金营,微信deep3321