基于 Qt6 Multimedia 的实时音频 RTP 传输方案报告

日期: 2025年12月15日
主题: 音频采集、编码、RTP打包发送及接收、解码、播放的实现
环境: Qt 6.x (C++), Network Module, Multimedia Module


1. 概述

本报告旨在阐述如何使用 Qt6 的多媒体和网络模块实现双向或单向的实时音频传输系统。系统主要包含两个核心链路:

  1. 发送端(Sender): 采集 PCM -> 编码(可选) -> RTP 封包 -> UDP 发送。
  2. 接收端(Receiver): UDP 接收 -> RTP 解包 -> 抖动缓冲(Jitter Buffer) -> 解码 -> PCM 播放。

在 Qt6 中,QAudioInputQAudioOutput 已被重构为 QAudioSourceQAudioSink,底层的音频流处理主要通过继承 QIODevice 来实现。


2. 系统架构设计

2.1 协议选择

  • 传输层: 使用 UDP。音频对实时性要求高,允许少量丢包,TCP 的重传机制会导致不可接受的延迟。
  • 应用层: 使用 RTP (Real-time Transport Protocol)。RTP 头部包含序列号(用于检测丢包和排序)和时间戳(用于同步播放),符合 RFC 3550 标准。

2.2 数据流向图

(此处描述图示:Microphone -> QAudioSource -> AudioInputDevice (Custom) -> Encoder -> RTP Packer -> QUdpSocket -> Network -> QUdpSocket -> RTP Unpacker -> Jitter Buffer -> Decoder -> AudioOutputDevice (Custom) -> QAudioSink -> Speaker)


3. 核心模块实现细节

3.1 RTP 数据包结构

为了标准通信,我们需要定义 RTP 头。一个最小化的 RTP 头结构如下:

#include <cstdint>

#pragma pack(push, 1) // 确保字节对齐
struct RtpHeader {
#if Q_BYTE_ORDER == Q_LITTLE_ENDIAN
    uint8_t cc:4;       // CSRC count
    uint8_t x:1;        // Header extension flag
    uint8_t p:1;        // Padding flag
    uint8_t version:2;  // Protocol version
    
    uint8_t pt:7;       // Payload type
    uint8_t m:1;        // Marker bit
#elif Q_BYTE_ORDER == Q_BIG_ENDIAN
    uint8_t version:2;
    uint8_t p:1;
    uint8_t x:1;
    uint8_t cc:4;
    
    uint8_t m:1;
    uint8_t pt:7;
#endif
    uint16_t sequenceNumber;
    uint32_t timestamp;
    uint32_t ssrc;
};
#pragma pack(pop)

3.2 发送端实现 (Sender)

发送端的核心逻辑是自定义一个继承自 QIODevice 的类(例如 RtpSenderDevice),并将其传递给 QAudioSource::start()

  1. 音频采集: 使用 QAudioSource 配置采样率(如 8000Hz)、通道数(1)和格式(Int16)。
  2. 编码(Encoding):writeData 中进行。
    • 简单方案: 直接发送 PCM(带宽占用大)。
    • 常用方案: G.711 (PCMA/PCMU)。这是一个简单的查找表或位运算算法,将 16-bit PCM 压缩为 8-bit,压缩率 2:1。
    • 高级方案: 集成 libopus(Qt6 本身不直接提供 Opus 编码 API 给原始 Buffer,需引入第三方库)。
  3. 打包发送: 将编码后的 Payload 加上 RTP 头,通过 QUdpSocket 发送。

代码逻辑示例 (Sender):

class RtpSenderDevice : public QIODevice {
    Q_OBJECT
public:
    RtpSenderDevice(const QHostAddress &addr, quint16 port, QObject *parent = nullptr)
        : QIODevice(parent), m_destAddr(addr), m_destPort(port) {
        m_socket = new QUdpSocket(this);
        m_sequenceNumber = 0;
        m_timestamp = 0;
    }

    // QAudioSource 会调用此函数写入采集到的 PCM 数据
    qint64 writeData(const char *data, qint64 len) override {
        // 1. 编码 (此处示例为透传 PCM,实际应用建议转 G.711 或 Opus)
        // char* encodedData = encode(data, len); 
        
        // 2. 准备 RTP 包
        int headerSize = sizeof(RtpHeader);
        QByteArray packet;
        packet.resize(headerSize + len); // 如果编码,len 变小
        
        RtpHeader *header = reinterpret_cast<RtpHeader*>(packet.data());
        memset(header, 0, headerSize);
        header->version = 2;
        header->pt = 0; // Payload Type 0 usually PCMU
        header->sequenceNumber = qToBigEndian(m_sequenceNumber++);
        header->timestamp = qToBigEndian(m_timestamp);
        header->ssrc = qToBigEndian(0x12345678);

        // 3. 填充 Payload
        memcpy(packet.data() + headerSize, data, len);

        // 4. 发送
        m_socket->writeDatagram(packet, m_destAddr, m_destPort);
        
        // 更新时间戳 (假设 8000Hz, Int16,len字节包含 len/2 个样本)
        m_timestamp += len / 2; 

        return len;
    }

    qint64 readData(char *data, qint64 maxlen) override { return 0; } // 发送端不读

private:
    QUdpSocket *m_socket;
    QHostAddress m_destAddr;
    quint16 m_destPort;
    uint16_t m_sequenceNumber;
    uint32_t m_timestamp;
};

3.3 接收端实现 (Receiver)

接收端较为复杂,需要处理网络抖动。我们需要一个自定义的 QIODevice(例如 RtpReceiverDevice),它包含一个缓冲区。QUdpSocket 收到数据写入缓冲区,QAudioSink 从缓冲区读取数据播放。

  1. 网络接收: QUdpSocket 绑定端口,监听 readyRead 信号。
  2. 解包与解码: 去掉 RTP 头,将 Payload 解码回 PCM(如 G.711 解码回 PCM16)。
  3. 缓冲与播放: 必须实现一个**环形缓冲区(Ring Buffer)**或简单的队列。如果网络数据来得慢,填充静音数据以防爆音;如果来得快,覆盖旧数据。

代码逻辑示例 (Receiver):

class RtpReceiverDevice : public QIODevice {
    Q_OBJECT
public:
    RtpReceiverDevice(QObject *parent = nullptr) : QIODevice(parent) {
        m_socket = new QUdpSocket(this);
        m_socket->bind(QHostAddress::Any, 12345);
        connect(m_socket, &QUdpSocket::readyRead, this, &RtpReceiverDevice::onReadyRead);
        open(QIODevice::ReadOnly);
    }

    // QAudioSink 会调用此函数索取 PCM 数据
    qint64 readData(char *data, qint64 maxlen) override {
        QMutexLocker locker(&m_mutex);
        if (m_buffer.isEmpty()) {
            // 缓冲区空,填充静音数据(0)
            memset(data, 0, maxlen);
            return maxlen;
        }

        qint64 len = qMin((qint64)m_buffer.size(), maxlen);
        memcpy(data, m_buffer.constData(), len);
        m_buffer.remove(0, len);
        return len;
    }

    qint64 writeData(const char *data, qint64 len) override { return 0; } // 接收端不写

private slots:
    void onReadyRead() {
        while (m_socket->hasPendingDatagrams()) {
            QNetworkDatagram datagram = m_socket->receiveDatagram();
            QByteArray packet = datagram.data();

            if (packet.size() <= (int)sizeof(RtpHeader)) continue;

            // 1. 去掉 RTP 头
            const char* payload = packet.constData() + sizeof(RtpHeader);
            int payloadLen = packet.size() - sizeof(RtpHeader);

            // 2. 解码 (如果是 G.711,此处解压为 PCM)
            // QByteArray pcmData = decode(payload, payloadLen);

            // 3. 写入缓冲区
            QMutexLocker locker(&m_mutex);
            m_buffer.append(payload, payloadLen); // 假设是 Raw PCM
            
            // 触发 AudioSink 读取
            emit readyRead(); 
        }
    }

private:
    QUdpSocket *m_socket;
    QByteArray m_buffer; // 简单缓冲区,实际建议使用 RingBuffer
    QMutex m_mutex;
};

3.4 主程序调用

void startVoIP() {
    QAudioFormat format;
    format.setSampleRate(8000);
    format.setChannelCount(1);
    format.setSampleFormat(QAudioFormat::Int16);

    // 发送端
    auto *senderDevice = new RtpSenderDevice(QHostAddress("192.168.1.100"), 12345);
    senderDevice->open(QIODevice::WriteOnly);
    
    auto *audioSource = new QAudioSource(QMediaDevices::defaultAudioInput(), format);
    audioSource->start(senderDevice);

    // 接收端
    auto *receiverDevice = new RtpReceiverDevice();
    // receiverDevice 已经在构造函数中 open 并在 readyRead 中处理数据
    
    auto *audioSink = new QAudioSink(QMediaDevices::defaultAudioOutput(), format);
    audioSink->start(receiverDevice);
}

4. 关键挑战与解决方案

4.1 延迟与抖动 (Jitter)

问题: 网络包到达时间不均匀,直接写入并播放会导致声音卡顿或忽快忽慢。
方案: 实现一个Jitter Buffer

  • 接收端不立即播放收到的包,而是放入一个有序队列。
  • 当队列中积累了少量数据(例如 40ms - 100ms)后才开始让 QAudioSink 读取。
  • 如果 RTP 序列号不连续,说明丢包,可以使用丢包隐藏算法(PLC)或简单的静音填充。

4.2 粘包与分包

问题: UDP 是面向报文的,通常不涉及粘包,但 MTU 是限制。
方案: 音频包通常很小(20ms 的 8000Hz PCM 仅 320 字节),远小于 MTU(1500 字节),因此无需分片,每个 UDP 包对应一个 RTP 包即可。

4.3 编码效率

问题: Raw PCM (16bit 8kHz) 需要 128kbps 带宽,局域网尚可,广域网压力大。
方案: 强烈建议集成 G.711 (PCMA/PCMU)

  • 实现简单:仅需查表或几行位移代码。
  • 带宽减半:64kbps。
  • Qt 中无内置 API,需自行封装 alaw2linearlinear2alaw 函数。

5. 总结

使用 Qt6 实现 RTP 音频流的核心在于QAudioSource/QAudioSinkQUdpSocket 通过自定义的 QIODevice 进行桥接

虽然 Qt6 Multimedia 提供了强大的跨平台音频硬件访问能力,但它并不包含 VoIP 协议栈。开发者需要自行处理:

  1. RTP 协议头的封装与解析。
  2. 音频数据的编码与解码(推荐至少使用 G.711)。
  3. 网络抖动的缓冲策略(这是保证通话质量最关键的一步)。

该方案适合局域网对讲、简单的远程监听等场景。如果是复杂的互联网通话,建议引入 WebRTC 库与 Qt 集成。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐