直播推流之 window QT 摄像头麦克风数据预览和采集

1,156 阅读11分钟

项目介绍

本项目是为了实现一个window环境的音视频直播推流或者说录制软件,本篇博客主要介绍使用qt的库,采集视频yuv数据,并实现预览,参数设置。可以截图。 采集麦克风数据,以及实时监测音频活动量。

效果图如下:

image.png

File菜单的开始按钮启动采集的start,可以启动yuv的写文件。后期就是是启动编码,封装,推流。万里长征第一步。

开发环境

  • VS 版本: Visual Studio Professional 2022 (64 位)
  • QT 版本: 5.12.0
  • c++ 语言

代码仓库

直播推流

代码结构截图

image.png

程序环境描述

程序使用qt环境开发,要添加qt的以下模块

image.png

vs 里面如何继承qt环境 参考我的博客 # vs studio 集成qt环境

主要代码介绍

  • main.cpp 主程序启动
  • MediaPushWindow.ui 主界面的ui文件
  • imagesettings.ui 视频采集的设置ui文件
  • MediaPushWindow.cpp 主ui的控制程序,以及负责主要逻辑的集成
  • CBaseCameraSurface.cpp 视频采集yuv的抽象类实现
  • AudioCapture.cpp 音频采集的实现文件

核心代码介绍

1. UI界面

1. MediaPushWindow.ui 是主程序界面,如下

image.png

本界面是参考官方example,File 菜单里面,关于启动和停止,设置。

  • VDevices ADevices里面列出系统可用的摄像头和麦克风列表。
  • 右边的focus是对焦按钮。
  • Image/Video是在摄像头支持的情况下,采集图片模式和视频模式(CaptureStillImage和CaptureVideo)。我的目前只支持CaptureStillImage,所以另外一种模式的代码,目前是删除的。
  • Capture Photo 按钮是截图,保存地址,会显示在状态栏里面。
  • Exposure Compensation 是曝光补偿。有效与否,看摄像头支持。

2. imagesettings.ui 设置界面

负责采集预览的分辨率,截图的图片格式。视频质量的设置,确定后实时生效,在start后,不能中途设置。 image.png

2. 初始化

MediaPushWindow 的构造函数是初始化过程

MediaPushWindow::MediaPushWindow(QWidget *parent)
    : QMainWindow(parent)
{
    ui.setupUi(this);

	//添加摄像头设备菜单
	QActionGroup* videoDevicesGroup = new QActionGroup(this);
	videoDevicesGroup->setExclusive(true);

	const QList<QCameraInfo> availableCameras = QCameraInfo::availableCameras();
	for (const QCameraInfo& cameraInfo : availableCameras) {
		QAction* videoDeviceAction = new QAction(cameraInfo.description(), videoDevicesGroup);
		videoDeviceAction->setCheckable(true);
		videoDeviceAction->setData(QVariant::fromValue(cameraInfo));
		if (cameraInfo == QCameraInfo::defaultCamera())
			videoDeviceAction->setChecked(true);

		ui.menuVDevices->addAction(videoDeviceAction);
	}
	//监听选择摄像头
	connect(videoDevicesGroup, &QActionGroup::triggered, this, &MediaPushWindow::updateCameraDevice);

	//添加麦克风设备菜单
	QActionGroup* audioDevicesGroup = new QActionGroup(this);
	audioDevicesGroup->setExclusive(true);
	QList<QAudioDeviceInfo> availableMics =QAudioDeviceInfo::availableDevices(QAudio::Mode::AudioInput);
	for (const QAudioDeviceInfo& audioInfo : availableMics) {
		QAction* audioDeviceAction = new QAction(audioInfo.deviceName(), audioDevicesGroup);
		audioDeviceAction->setCheckable(true);
		audioDeviceAction->setData(QVariant::fromValue(audioInfo));
		if (audioInfo == QAudioDeviceInfo::defaultInputDevice());
			audioDeviceAction->setChecked(true);

		ui.menuADevices->addAction(audioDeviceAction);
	}
	//监听麦克风设备
	connect(audioDevicesGroup, &QActionGroup::triggered, this, &MediaPushWindow::updateMicDevice);
	
	//视频的模式切换
	connect(ui.captureWidget, &QTabWidget::currentChanged, this, &MediaPushWindow::updateCaptureMode);

	//设置摄像头
	setCamera(QCameraInfo::defaultCamera());
	//设置麦克风
	setMic(QAudioDeviceInfo::defaultInputDevice());

	ui.actionStart->setEnabled(true);
	ui.actionStop->setEnabled(false);
	ui.actionSettings->setEnabled(true);

	renderArea = new RenderArea(this);
	renderArea->setLevel(0.01);
	//renderArea->resize(600, 30);
	ui.statusbar->addWidget(renderArea);

	ui.statusbar->setStyleSheet("background-color: white;");

}

RenderArea 是用于实时展示麦克风活动量的状态。就是底部红色条。可以查看自己使用的麦克风是否有效数据。

  • 初始化的时候,是没有启动写文件的,所以file/start,file/setings 按钮是可用的。
  • updateCaptureMode 是右边的那个tab的切换的,会更具是支持,如果不支持,就不能切换。比如我的这里video是不支持的。
  • updateCameraDevice,updateMicDevice 就是触发不同mic,camera的设备切换时候触发的。里面调用的也就是setCamera,setMic发方法
	//设置摄像头
	setCamera(QCameraInfo::defaultCamera());
	//设置麦克风
	setMic(QAudioDeviceInfo::defaultInputDevice());
  • 启动默认设备。setCamera,setMic 也是实现的核心启动逻辑

3. 启动摄像头

void MediaPushWindow::setCamera(const QCameraInfo& cameraInfo)
{
	m_camera.reset(new QCamera(cameraInfo));

	connect(m_camera.data(), &QCamera::stateChanged, this, &MediaPushWindow::updateCameraState);
	connect(m_camera.data(), QOverload<QCamera::Error>::of(&QCamera::error), this, &MediaPushWindow::displayCameraError);

	m_imageCapture.reset(new QCameraImageCapture(m_camera.data()));

	connect(ui.exposureCompensation, &QAbstractSlider::valueChanged, this, &MediaPushWindow::setExposureCompensation);

	if (showUserViewFinder)
	{
		m_camera->setViewfinder(ui.viewfinder);
	}
	else
	{
		//创建yuv采集器
		if (!m_pCameraInterface)
		{
			m_pCameraInterface.reset(new CBaseCameraSurface(this));
		}
		m_camera->setViewfinder(m_pCameraInterface.data());
		displayCapturedImage();
		connect(m_pCameraInterface.data(), SIGNAL(frameAvailable(QVideoFrame&)), this, SLOT(recvVFrame(QVideoFrame&)));
	}


	updateCameraState(m_camera->state());
	updateLockStatus(m_camera->lockStatus(), QCamera::UserRequest);

	connect(m_imageCapture.data(), &QCameraImageCapture::readyForCaptureChanged, this, &MediaPushWindow::readyForCapture);
	connect(m_imageCapture.data(), &QCameraImageCapture::imageCaptured, this, &MediaPushWindow::processCapturedImage);
	connect(m_imageCapture.data(), &QCameraImageCapture::imageSaved, this, &MediaPushWindow::imageSaved);
	connect(m_imageCapture.data(), QOverload<int, QCameraImageCapture::Error, const QString&>::of(&QCameraImageCapture::error),
		this, &MediaPushWindow::displayCaptureError);

	connect(m_camera.data(), QOverload<QCamera::LockStatus, QCamera::LockChangeReason>::of(&QCamera::lockStatusChanged),
		this, &MediaPushWindow::updateLockStatus);

	QList<QVideoFrame::PixelFormat> list = m_camera->supportedViewfinderPixelFormats();
	
	ui.captureWidget->setTabEnabled(0, (m_camera->isCaptureModeSupported(QCamera::CaptureStillImage)));
	ui.captureWidget->setTabEnabled(1, (m_camera->isCaptureModeSupported(QCamera::CaptureVideo)));

updateCaptureMode();
	m_camera->start();

}

m_camera 是QT的QCamera类型,每次切换一个摄像头的时候,重新创建一个

updateCameraState方法里面更具摄像头启动状态。去设置一个ui的可用与否。

displayCameraError 显示一些camera的异常错误

setExposureCompensation 是监听设置的曝光补偿的值,然后设置

m_imageCapture 是QCameraImageCapture类型,主要用于截图的一些监听,以下就是它的几个监听方法,主要是准备截图,截图保存的处理

	connect(m_imageCapture.data(), &QCameraImageCapture::readyForCaptureChanged, this, &MediaPushWindow::readyForCapture);
	connect(m_imageCapture.data(), &QCameraImageCapture::imageCaptured, this, &MediaPushWindow::processCapturedImage);
	connect(m_imageCapture.data(), &QCameraImageCapture::imageSaved, this, &MediaPushWindow::imageSaved);
	connect(m_imageCapture.data(), QOverload<int, QCameraImageCapture::Error, const QString&>::of(&QCameraImageCapture::error),
		this, &MediaPushWindow::displayCaptureError);

updateLockStatus 是设置对焦状态,也是控制focus按钮的状态。

接下来就是设置图片模式和视频模式的状态

	if (showUserViewFinder)
	{
		m_camera->setViewfinder(ui.viewfinder);
	}
	else
	{
		//创建yuv采集器
		if (!m_pCameraInterface)
		{
			m_pCameraInterface.reset(new CBaseCameraSurface(this));
		}
		m_camera->setViewfinder(m_pCameraInterface.data());
		displayCapturedImage();
		connect(m_pCameraInterface.data(), SIGNAL(frameAvailable(QVideoFrame&)), this, SLOT(recvVFrame(QVideoFrame&)));
	}

以上是核心,showUserViewFinder是我自己加的变量。如果只是预览,那么可以使用一个 qt自带的QCameraViewfinder的类型,ui.viewfinder 。 但是无法采集到yuv数据。当为false状态的时候。m_pCameraInterface是一个根据QAbstractVideoSurface抽象类实现的采集器器。里面的实现后面介绍。连接它的信号 frameAvailable可以在recvVFrame的槽函数里面收到QVideoFrame方法。在recvVFrame方法里面,我们可以把视频帧渲染到widget里面。

最后调用camera的start方法,就启动了预览采集。

4. 启动麦克风

void MediaPushWindow::setMic(const QAudioDeviceInfo& cameraInfo)
{
	m_mic.reset(new AudioCapture());

	connect(m_mic.data(), SIGNAL(aframeAvailable(const char* data, qint64 len)), this, SLOT(recvAFrame(const char* data, qint64 len)));
	
	//监听麦克风的level
	connect(m_mic.data(), &AudioCapture::updateLevel, [this]() {
			renderArea->setLevel(m_mic->level());
	});
	m_mic->Start(cameraInfo);
}

每次切换一个麦克风,重新创建一个 AudioCapture。

updateLevel是音频采集活动量的信号,设置到renderArea里面,用来判断是否有声音。

后面启动麦克风。关于这个AudioCaptrue单独介绍

5. 接收处理视频数据

recvVFrame 是接收处理数据的地方

void MediaPushWindow::recvVFrame(QVideoFrame& frame) {
	frame.map(QAbstractVideoBuffer::ReadOnly);

	// 获取 YUV 编码的原始数据
	QVideoFrame::PixelFormat pixelFormat = frame.pixelFormat();
	
	//需要写文件的时候才去转换数据
	if (pixelFormat == QVideoFrame::Format_RGB32 && yuv_out_file)
	{
		

		int width = frame.width();
		int height = frame.height();

		if (dst_yuv_420 == nullptr)
		{
			dst_yuv_420 = new uchar[width * height * 3 / 2];
			memset(dst_yuv_420, 128, width * height * 3 / 2);
		}

		int planeCount = frame.planeCount();
		int mapBytes = frame.mappedBytes();
		int rgb32BytesPerLine = frame.bytesPerLine();
		const uchar* data = frame.bits();
		if (data == NULL)
		{
			return;
		}

		QVideoFrame::FieldType fileType = frame.fieldType();

		int idx = 0;
		int idxu = 0;
		int idxv = 0;
		for (int i = height-1;i >=0 ;i--)
		{
			for (int j = 0;j < width;j++)
			{
				uchar b = data[(width*i+j) * 4];
				uchar g = data[(width * i + j) * 4 + 1];
				uchar r = data[(width * i + j) * 4 + 2];
				uchar a = data[(width * i + j) * 4 + 3];
				
				if (rgb_out_file) {
					//fwrite(&b, 1, 1, rgb_out_file);
					//fwrite(&g, 1, 1, rgb_out_file);
					//fwrite(&r, 1, 1, rgb_out_file);
					//fwrite(&a, 1, 1, rgb_out_file);
				}

				uchar y = RGB2Y(r, g, b);
				uchar u = RGB2U(r, g, b);
				uchar v = RGB2V(r, g, b);

				dst_yuv_420[idx++] = clip_value(y,0,255);
				if (j % 2 == 0 && i % 2 == 0) {
				
					dst_yuv_420[width*height + idxu++] = clip_value(u,0,255);
					dst_yuv_420[width*height*5/4 + idxv++] = clip_value(v,0,255);
				}
			}
		}
		if (yuv_out_file) {
			fwrite(dst_yuv_420, 1, width * height * 3 / 2, yuv_out_file);
		}

	}
	
	if (!showUserViewFinder)
	{
		QImage::Format qImageFormat = QVideoFrame::imageFormatFromPixelFormat(frame.pixelFormat());
		QImage videoImg(frame.bits(),
			frame.width(),
			frame.height(),
			qImageFormat);

		videoImg = videoImg.convertToFormat(qImageFormat).mirrored(false, true);


		ui.lastImagePreviewLabel->setPixmap(QPixmap::fromImage(videoImg));
	}

}

frame.map(QAbstractVideoBuffer::ReadOnly); 这一行比较重要,这会把数据注入frame。

frame.pixelFormat(); 可以获取frame的piexlformat格式,我这里获得是 Format_RGB32。我这里加了个判断,yuv_out_file 没创建,也就是没有启动的时候,我这里不处理。

接下来就是根据 宽高 分配 dst_yuv_420空间。

		int planeCount = frame.planeCount();
		int mapBytes = frame.mappedBytes();
		int rgb32BytesPerLine = frame.bytesPerLine();

planeCount 是有多少个平面,因为是rgb32的,所以是只有一个平面。 mapBytes是frame里面总共的rgb数据量。rgb32BytesPerLine 返回一行数据量。比如这里就是 width*4。 因为每个像素点占用 r,g,b,a四个字节。

接下来,我读出了frame里面的r,g,b,a值

注意这里虽然叫rgb32,但数据的排列顺序是b,g,r,a。 而且是从底部往上扫描的。所以i的起点是height-1。不然,你一会转换出来的yuv就是上下颠倒的。

rgb24 又不一样,它是b,g,r。但是它是从上往下扫描的,自己可以去调整,研究这个rgb的格式

#define RGB2Y(r,g,b) \
	((unsigned char)((66 * r + 129 * g + 25 * b + 128) >> 8)+16)

#define RGB2U(r,g,b) \
	((unsigned char)((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128)

#define RGB2V(r,g,b) \
	((unsigned char)((112 * r - 94 * g - 18 * b + 128) >> 8) + 128)

这三个宏方法,可以转换rgb数据为yuv,注意 uv分量只有y分量的4分之一。

当 showUserViewFinder 为false的时候,也就是用我自己写的采集器预览的时候,我们把QVideoFrame转换为QImag。

注意上下镜像反转一下。

然后 通过 setPixmap方法,把它绘画到一个label组件里面,就可以达到预览的效果。

这里写pcm文件的地方,后面就会改成编码,和推流。

6. 接收处理音数据

void MediaPushWindow::recvAFrame(const char* data, qint64 len)
{
	if (pcm_out_file) {
		fwrite(data, 1, len, pcm_out_file);
	}
}

比较简单,写文件就是

7. 视频采集器详细介绍

CBaseCameraSurface 是实现的视频采集器,它是QAbstractVideoSurface抽象类的实现。

	virtual bool isFormatSupported(const QVideoSurfaceFormat& format) const override;
	virtual bool present(const QVideoFrame& frame) override;
	virtual bool start(const QVideoSurfaceFormat& format) override;
	virtual void stop() override;
	virtual QList<QVideoFrame::PixelFormat> supportedPixelFormats(QAbstractVideoBuffer::HandleType type = QAbstractVideoBuffer::NoHandle) const override;

它的以上几个方法是需要被实现的。 isFormatSupported 是判断某种格式是否支持的

bool CBaseCameraSurface::isFormatSupported(const QVideoSurfaceFormat& format) const {
	return QVideoFrame::imageFormatFromPixelFormat(format.pixelFormat()) != QImage::Format_Invalid;
}

supportedPixelFormats 是返回支持的格式列表,可以更具平台实际定义

QList<QVideoFrame::PixelFormat> CBaseCameraSurface::supportedPixelFormats(QAbstractVideoBuffer::HandleType handleType) const
{
	if (handleType == QAbstractVideoBuffer::NoHandle) {
		return QList<QVideoFrame::PixelFormat>() << QVideoFrame::Format_RGB32
			<< QVideoFrame::Format_ARGB32
			<< QVideoFrame::Format_ARGB32_Premultiplied
			<< QVideoFrame::Format_RGB565
			<< QVideoFrame::Format_RGB555;
		//这里添加更多的类型,因为有很多摄像头返回的数据类型是yuv格式的
	}
	else {
		return QList<QVideoFrame::PixelFormat>();
	}
}

start方法,就是判断了一下条件,然后调用父类start

bool CBaseCameraSurface::start(const QVideoSurfaceFormat& format) {
	//要通过QCamera::start()启动
	if (QVideoFrame::imageFormatFromPixelFormat(format.pixelFormat()) != QImage::Format_Invalid
		&& !format.frameSize().isEmpty()) {
		QAbstractVideoSurface::start(format);
		return true;
	}
	return false;
}

stop直接调用父类的就可以

present 方法里面就是获取视频帧了,这里拷贝一下,并且发出信号,调用到外层的recvVFrame

bool CBaseCameraSurface::present(const QVideoFrame& frame) {
	//QCamera::start()启动之后才会触发
	//qDebug()<<"camera present";
	if (!frame.isValid()) {
		qDebug() << "get invalid frame";
		stop();
		return false;
	}

	QVideoFrame cloneFrame(frame);
	emit frameAvailable(cloneFrame);
	return true;
}

8. 音频采集器详细介绍

音频采集器依然是使用 QIODevice 和 QAudioInput结合实现。QIODevice是一个输入输出管道

void AudioCapture::Start(const QAudioDeviceInfo& micInfo)
{
    format = micInfo.preferredFormat();

    switch (format.sampleSize()) {
    case 8:
        switch (format.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 255;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 127;
            break;
        default:
            break;
        }
        break;
    case 16:
        switch (format.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 65535;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 32767;
            break;
        default:
            break;
        }
        break;

    case 32:
        switch (format.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 0xffffffff;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 0x7fffffff;
            break;
        case QAudioFormat::Float:
            m_maxAmplitude = 0x7fffffff; // Kind of
        default:
            break;
        }
        break;

    default:
        break;
    }

    if (!micInfo.isFormatSupported(format)) {
        QMessageBox::warning(nullptr, tr("Audio Capture Error"), "format is not support");
        return;
    }

    qDebug() << "default devicdName: " << micInfo.deviceName() << " : ";
    qDebug() << "sameple: " << format.sampleRate() << " : ";
    qDebug() << "channel:  " << format.channelCount() << " : ";
    qDebug() << "fmt: " << format.sampleSize() << " : ";

    audioInput = new QAudioInput(micInfo, format);

    // 连接errorOccurred信号
    //QObject::connect(audioInput, &this::error,this,&AudioCapture::OnError);

    audioInput->start(this);

    this->open(QIODevice::WriteOnly);

  //  QMessageBox::warning(this, tr("Image Capture Error"), errorString);

}

在start方法里面,根据外边选择的设备,preferred 返回设备首选的音频采集format,里面就包含了采样率,声道,fmt等信息。

QAudioInput 注入设备信息,和format信息。然后就start了。

AudioCapture 是集成QIODevice的。启动后。数据就会通过重写的方法 writeData输出采集的pcm数据。

qint64 AudioCapture::writeData(const char* data, qint64 len)
{
    // 在这里处理音频数据,例如保存到文件、进行处理等
    qDebug() << "Received audio data. Size:" << len;
    CaculateLevel(data,len);

    emit aframeAvailable(data,len);
    return len;
}

采集到数据后,我们先将数据传入 CaculateLevel 来前置计算活动量。这个算法,来自于qt的官方例子。可以作为储备程序。

RenderArea是一个手绘类,在其他窗口创建。CaculateLevel加速那出level值(0-1),发送型号到外面就可以绘画活动量到RernderArea里面。

writeData方法里面,然后发送信号aframeAvailable,在主窗口,就可以接收音频pcm数据,做自己的处理了。

9. 视频设置

ImageSettings 是一个QDialog弹出框。在弹出的时候,传入,当前的设置,执行完之后,返回设置参数,设置到m_imageCapture里面

void MediaPushWindow::configureImageSettings()
{
	ImageSettings settingsDialog(m_imageCapture.data());
	settingsDialog.setWindowFlags(settingsDialog.windowFlags() & ~Qt::WindowContextHelpButtonHint);

	settingsDialog.setImageSettings(m_imageSettings);

	if (settingsDialog.exec()) {
		m_imageSettings = settingsDialog.imageSettings();
		m_imageCapture->setEncodingSettings(m_imageSettings);
	}
}

QCameraImageCapture::supportedImageCodecs 是返回支持的图片格式

QCameraImageCapture::supportedResolutions 是支持的分辨率

QMultimedia::VeryHighQuality 是质量的支持,是个枚举类型

这里虽然没有和CBaseCameraSurface直接联系,但是它改变了摄像头参数,所以实际使用的时候,它修改了截图的分辨率参数,同时也改动了视频yuv采集yuv的格式。虽然它看起来和自定的采集yuv没什么关系,所以说这些设置,都是改到摄像头原始位置的。

结束语言

有了这个采集端,和之前写的播放端,后期,我们就可以做出很多应用来,因为视频无非就是输入,输出。首先我们会加入编码,然后加入录制,推流,或者入会,等各种功能。

难点注意,

在阅读和调试的时候,大家需要对yuv,和pcm有所了解,大家可以先了解相关知识。在这个项目里面,还涉及rgb格式。

7. 其他: