Qt使用QAudioInput、QAudioOutput实现局域网的音频通话

本文旨在介绍一下用Qt来实现局域网音频通话功能

项目背景

最近项目需要,要制作一个局域网的音频通话软件,所以就动手写了一个局域网音频通话软件。

技术实现

  1. QAudioInput、QAudioOutput(Qt采集和播放音频类)
  2. QUdpSocket(Qt的UDP通信类)

  话不多说,直接上代码链接,想下载的朋友可以直接去gitee下载。
  整体的思路就是,读取声卡的数据,通过UDP发送出去,同时也会读取UDP发送过来的流的数据,写入到音频播放设备里进行播放。
以下是一些比较简单的对这两个技术点的解释,以及部分代码实现细节。

QAudioFormat(音频采样格式)

这个类,保存了音频流的参数信息。主要的参数有:

Parameter Description
Sample Rate(采样频率) Samples per second of audio data in Hertz.
Sample Channels(采样通道数) Number of channelsThe number of audio channels (typically one for mono or two for stereo)
Sample size(采样位数) How much data is stored in each sample (typically 8 or 16 bits)
Sample type(采样种类) Numerical representation of sample (typically signed integer, unsigned integer or float)
Byte order(字节序) Byte ordering of sample (typically little endian, big endian)

详细的音频采集知识请看:科普常识:常用音频参数解析。而在实际使用中,我们一般只关注Sample Rate(采样频率)Sample Size(采样位数)
采样频率代表,在一秒钟里面,采样的音频的数量。采样频率越大,就代表这个声音的振幅越准确,换言之就是声音的质量也就越高
采样位数代表,对采样的声音的振幅等级数量。采样位数越大,声音振幅的划分越细,得到的声音的就越真实,噪声就越少

QAudioDeviceInfo

这个类是用来保存音频播放设备的一些信息的,在这里,我们主要用来获取设备所支持的语音格式。

QAudioInput、QAudioOutput

这两个类,是Qt中的用于采集和播放音频的类。简单的用法如下:

// 设置音频采样的参数
m_format.setSampleRate(8000);
m_format.setChannelCount(1);
m_format.setSampleSize(8);
m_format.setCodec("audio/pcm");
m_format.setByteOrder(QAudioFormat::LittleEndian);
m_format.setSampleType(QAudioFormat::SignedInt);
QAudioDeviceInfo info = QAudioDeviceInfo::defaultInputDevice();
if (!info.isFormatSupported(m_format)) {
    qWarning() << "Default format not supported, trying to use the nearest.";
    m_format = info.nearestFormat(m_format);
}

// 用采样的参数来实例化一个QAudioInput对象
m_audioInput = new QAudioInput(m_format);

// 用采样的参数来实例化一个QAudioOutput对象
m_audioOutput = new QAudioOutput(m_format, this);
m_outputDevice = m_audioOutput->start();

这两个类有一个函数start( ),这个函数会开启音频的读取或者写入,并返回一个对应的QIODevice,用来从设备里读取和写入音频数据。

当通话接通的时候,打开QAudioInput,将音频流数据,通过UDP发送到对方端口。

void MainWindow::slot_callResponse(int response)
{
    ui->stackedWidget->setCurrentIndex(0);
    m_dialogTimer.stop();
    if (response == 0) {
        slot_connected();
        m_inputDevice = m_audioInput->start();
        connect(m_inputDevice, &QIODevice::readyRead, this, &MainWindow::slot_sendAudioData, Qt::UniqueConnection);
    } else if (response == 1) {
        // TODO 添加拒绝通话时,将等待框关掉
    }

}

void MainWindow::slot_sendAudioData()
{
    m_socket.writeDatagram(m_inputDevice->read(1024), QHostAddress(m_targetIP), m_targetPort);
}

QUdpSocket

这个类是Qt的udp通信的类,详细的类的介绍,可以看Qt的帮助文档。在这个项目,主要用到了几个函数:

  1. bind

  这个函数用来绑定到某个ip和端口上,代表发到这个ip和这个端口上的数据,能被当前socket认为是发给自己的。当然,如果你仅仅只要发送udp数据的话,是不需要进行bind的。

  1. readyRead

  这是一个信号,当数据准备好可以读取的时候,就会发射这个信号。这个时候,就可以调用reciveDatagram来读取数据。
使用代码如下:

void RecvData::slot_start()
{
    qDebug() << QThread::currentThread();
    QString dir = QApplication::applicationDirPath();
    QSettings settings(dir+"/config.ini", QSettings::IniFormat);
    int port = settings.value("Network/hostPort").toInt();
    QString ip = settings.value("Network/hostIP").toString();

    m_socket = new QUdpSocket;
    int ret = m_socket->bind(QHostAddress(ip), port);
    qDebug() << ip << port;
    if (!ret) {
        QString error =  QString("%1:%2 绑定失败, 原因: %3")
                            .arg(ip)
                            .arg(port)
                            .arg(m_socket->errorString());
        Q_EMIT signal_bindFailed(error);
    }

    connect(m_socket, &QUdpSocket::readyRead, this, &RecvData::slot_writeDataToOutput);
}

在收到UDP的数据时,会对数据进行解析,然后通过信号和槽的方式来执行对应的步骤:

int RecvData::analysisData(const QByteArray &data)
{
    if (data.size() > 30)
        return 0;
    
    if (data == m_protocolManager.protocolContent(Protocol::CallRequest)) {
        m_connectStatus = ConnectStatus::Connected;
        Q_EMIT signal_callRequest();
    }

    if (data == m_protocolManager.protocolContent(Protocol::Accept)) {
        m_connectStatus = ConnectStatus::Connected;
        Q_EMIT signal_callResponse(0);
    }

    if (data == m_protocolManager.protocolContent(Protocol::Refuse)) {
        m_connectStatus = ConnectStatus::Disconnected;
        Q_EMIT signal_callResponse(1);
    }

    if (data == m_protocolManager.protocolContent(Protocol::HangUp)) {
        m_connectStatus = ConnectStatus::Disconnected;
        Q_EMIT signal_hangUp();
    }

    if (data == m_protocolManager.protocolContent(Protocol::Cancel)) {
        m_connectStatus = ConnectStatus::Disconnected;
        Q_EMIT signal_callCancel();
    }
    
    return 1;
}

如果是音频的数据,就直接将数据写入到QAudioOutput开启时返回的QIODevice里,

void RecvData::slot_writeDataToOutput()
{
    QNetworkDatagram datagram = m_socket->receiveDatagram();
    int ret = analysisData(datagram.data());
    if (ret == 1)
        return;
    if (m_connectStatus != ConnectStatus::Connected)
        return;
    int writeSize = m_outputDevice->write(datagram.data());
    Q_UNUSED(writeSize)
}

踩过的坑

  1. 音频采集时,出现很大的杂音

  这个问题,在介绍完音频的各种参数之后就开始了解了,但是当时做的时候,一个劲的去加载采样频率,但是发现根本就不起作用。于是怀疑是不是因为没有降噪算法的加持,所以导致有很大的噪音。但是偶然在网上发现说QAudioRecord录制的音频,播放效果比QAudioOutput效果好多了,于是我就很纳闷,后面发现,是因为QAudioRecord设置了一个高质量的参数,所以就采样效果很好。于是,我才找到上面那片文章对应的每一个音频采集的参数效果,最后把Sample Size设置成了16之后,效果就好很多了。

  1. 协议的指定以及部分的逻辑的编写

  另外一个比较棘手的问题就是关于双方协议的编写,主要是需要考虑接听、挂断、拒绝、超时接听等情况都考虑在内,所以协议就有点麻烦。

  1. 本机的音频参数和对端的音频参数不一致

  早期的时候,我对这个没有经验,我没有写音频的参数可配置以及也没有进行检验,这种会出现,很多都是不很好的,然后比较脏的问题。解决方法就是:使用配置文件,来解决不同配置的问题