☰
嵌入式Linux下Qt播放PCM音频:用QAudioOutput在开发板上奏响欢乐颂
2026/10/14 18:23:13 网站建设 项目流程

简介:面向嵌入式开发与Qt应用学习者,这份资源以S3C6410开发板为运行平台,演示了如何用Qt框架实现《欢乐颂》旋律播放、跑马灯控制与物理按键交互,将图形界面、GPIO驱动和蜂鸣器发声整合于一个小型工程,是理解嵌入式GUI应用从工程创建到硬件控制全流程的轻量范例。压缩包共10个文件,主体为3个cpp与2个h源码,分别负责按键响应、音乐解码输出和LED流水逻辑,另附pro工程描述、qrc资源集合及两张界面配图,包体仅35KB,结构紧凑,适合逐文件研读。工程从main入口启动,借助信号与槽机制监听按键,进而驱动蜂鸣器发声并切换LED跑马灯模式,相关代码可直接在Linux环境交叉编译后下载到板卡运行。对于想快速上手嵌入式图形开发或了解6410外设操作的同学,这份资源提供了完整可对照的源码,便于在其基础上调整曲谱或增加调速功能。目前已有307人学习下载,是一份兼具趣味与实用性的实战教程。

1. 在Linux开发板上让Qt“唱”出欢乐颂:一个比点灯更合适的嵌入式起点

很多做嵌入式Linux开发的工程师,第一次上板实验都是点灯,第二次往往就想让板子发出声音。点灯只需要看GPIO电平,发声却要牵动Qt的音频后端、ALSA设备节点和采样率三层链路,卡住的人不在少数。我选欢乐颂作为素材,因为这段旋律大家都熟,而且全曲是单音旋律,拿来验证“Qt在Linux开发板上能不能出声”再合适不过。

这篇文章要做的不是播放一首MP3,而是用Qt的QAudioOutput手动合成PCM正弦波:把简谱变成频率表,把频率表变成采样点,把采样点喂给声卡。你会发现这其实是一条非常可控的链路,交叉编译、部署、排错都有章可循。

适合的人群很明确:正在做课程设计想展示一个能出声的Qt程序的同学,产品里需要开机提示音或事件提示音的开发者,以及想把Qt多媒体后端彻底搞明白的人。我会把选型理由、完整代码、交叉编译部署和排查顺序都过一遍,照着走完,你的开发板也能在开机时响一段欢乐颂。

2. 播放路线怎么选:QAudioOutput这一条最稳

在Linux开发板上让Qt出声,绕不开一个选型问题:用哪个类来播放。QSound、QMediaPlayer、QAudioOutput三条路,网上教程都有,但放在嵌入式板上差距非常大。我先说结论:合成单音旋律这种场景,QAudioOutput + 自建PCM缓冲区是翻车率最低的方案,其它两个类各有各的坑。

2.1 三条路的取舍:为什么QMediaPlayer在板子上容易翻车

QSound是Qt4时代遗留的类,只能播放完整的wav文件,不支持动态生成的数据,连音量控制都弱,在Qt5里虽然还能用,但官方已经不推荐,功能太受限。

QMediaPlayer走的是多媒体框架,桌面上它背后是gstreamer或者Windows Media Foundation,在开发板上事情就麻烦了。交叉编译时要带一堆后端插件,解码器缺一个,播放就静默失败,而且它面向的是文件或URL,不是内存里的一段PCM数据。嵌入式板子的文件系统往往裁剪过,gstreamer的插件不齐是常态,用QMediaPlayer经常是编译过了、运行没声音,查起来无从下手。

QAudioOutput直接面向低层音频设备:你把PCM数据给它,它通过ALSA写到声卡。没有解码环节,不依赖codec,交叉编译只需要QtMultimedia模块,裁剪过的rootfs也能跑。放出来的音色是纯粹的波形,没有压缩,可控性最强。我把三条路的特性整理成一张表:

特性QSoundQMediaPlayerQAudioOutput
支持内存中的PCM数据否需要封装成流,麻烦直接喂QByteArray
依赖多媒体后端和codec否是,板子上常被裁剪否
交叉编译体积小大,插件多小
适合动态合成旋律不适合不适合非常适合

所以我在这类项目里固定用QAudioOutput。如果你的需求是放一首完整的MP3,那是另外一个话题,得硬着头皮去处理多媒体后端;但欢乐颂这种“需要自己生成音符”的场景,走QAudioOutput是性价比最高的路线。

2.2 简谱到音符:C大调频率表和时值怎么算

要让板子发声,第一步是把简谱翻译成频率和时值。标准音A4的频率是440Hz,其余音符用十二平均律公式计算:f = 440 * 2^(n/12),n是相对A4的半音数。C4在A4下方9个半音,所以C4的频率就是440 * 2^(-9/12) ≈ 261.63Hz。

我常用的C大调音阶频率表如下,代码里可以直接用常量,不用手算:

音符频率(Hz)在旋律中对应简谱
C4261.631
D4293.662
E4329.633
F4349.234
G4392.005

时值直接用秒。我习惯把一拍定义成0.45秒,大约是133BPM的行进速度,听感比较从容。四分音符就是0.45秒,二分音符是0.9秒,附点四分音符是0.675秒。欢乐颂主旋律前两行简谱是“3 3 4 5 | 5 4 3 2 | 1 1 2 3 | 3. 2 2 -”,对应到音符序列就是E4 E4 F4 G4 G4 F4 E4 D4 C4 C4 D4 E4,最后落在附点E4、D4和二分D4上。这里不需要把原曲的每个装饰音都还原,工程演示抓主干旋律就够了。

2.3 硬件先决条件:先确认你的板子靠什么出声

动手写代码之前,先确认开发板上有没有音频输出硬件。在板子的串口终端或者SSH里跑两条命令:

ls /dev/snd/ aplay -l

如果/dev/snd下面有controlC0、pcmC0D0p这类节点,或者aplay -l能列出声卡设备,说明板子上有音频codec,直接走ALSA路线,本文的QAudioOutput方案就是为这种板子准备的。

如果没有任何音频设备节点,板子上通常只有一个无源蜂鸣器接在某个GPIO上。这时候也可以让Qt放旋律,但音准会变得很玄学,细节我在第4章的排查部分专门讲。记住一个前提:有源蜂鸣器只能响和不响,不能变调,必须是无源蜂鸣器才能靠方波频率控制音高。判断方法很简单,给蜂鸣器通恒定直流电,会一直响的是有源,不响的是无源。

3. 上板实操:从音符表到扬声器出声的完整链路

选型定下来之后,整个实现分为四个环节:建工程、合成PCM、配置QAudioOutput、交叉编译部署。每个环节都有必须写对的参数,我按顺序过一遍。

3.1 工程结构和Qt版本确认

先确认你手里的Qt是哪个大版本,因为QAudioOutput在Qt5和Qt6里的API不兼容,代码写错一个版本就编译不过。

qmake -v

交叉编译工具链自带的qmake会显示类似“Qt 5.15.2”的版本号。我这套工程基于Qt5写法,工程文件如下:

QT += multimedia CONFIG += c++11 TARGET = ode_to_joy SOURCES += main.cpp

只有multimedia一个模块,没有widgets,因为播放旋律只需要QCoreApplication,不需要界面。如果你的工程里还要显示乐谱或播放状态,那就把QT += widgets加上,改成QApplication。工程文件里最容易被忽略的是CONFIG += c++11,我的代码里用了std::pow和std::sin,C++标准不够会报错。

3.2 合成PCM:正弦波采样和节拍控制

音频设备不认识音符,它只认采样点。我的做法是把每一个音符生成一小段正弦波,按顺序拼成一个完整的QByteArray,一次性交给音频设备。核心函数如下:

#include <QByteArray> #include <cmath> // 追加一个音符的正弦波采样到pcm缓冲区 // freq: 音符频率(Hz),seconds: 时值(秒),sampleRate: 采样率 void appendTone(QByteArray &pcm, double freq, double seconds, int sampleRate) { int totalSamples = static_cast<int>(seconds * sampleRate); int fadeSamples = sampleRate / 200; // 5ms淡入淡出,防爆音 for (int i = 0; i < totalSamples; ++i) { double t = static_cast<double>(i) / sampleRate; // 16位PCM范围是-32768到32767,振幅16000留了余量 short sample = static_cast<short>(16000.0 * sin(2.0 * M_PI * freq * t)); if (i < fadeSamples) { sample = static_cast<short>(sample * i / fadeSamples); } else if (i > totalSamples - fadeSamples) { sample = static_cast<short>(sample * (totalSamples - i) / fadeSamples); } pcm.append(reinterpret_cast<const char*>(&sample), sizeof(sample)); } }

这段代码里振幅16000是刻意选择的,靠近满幅但又不会削波。回归测试时如果发现音量偏大导致声卡输出失真,可以把振幅降到12000或者10000。M_PI在部分编译环境下需要先定义_USE_MATH_DEFINES才能用,交叉编译工具链偶尔会踩到这个,编译报错时在文件顶部加一行#define _USE_MATH_DEFINES即可。

3.3 用QAudioOutput播放:格式参数一个都不能错

PCM拼好后,主函数负责配置音频格式并启动播放。这里最容易出错的是QAudioFormat的五个参数,丢一个或者写错一个,start就会失败甚至整个程序崩溃。

#include <QCoreApplication> #include <QAudioOutput> #include <QBuffer> #include <QTimer> #include <cmath> struct Note { double freq; double seconds; }; // C大调音阶,A4=440Hz基准 static const double C4 = 440.0 * std::pow(2.0, -9.0 / 12.0); static const double D4 = 440.0 * std::pow(2.0, -7.0 / 12.0); static const double E4 = 440.0 * std::pow(2.0, -5.0 / 12.0); static const double F4 = 440.0 * std::pow(2.0, -4.0 / 12.0); static const double G4 = 440.0 * std::pow(2.0, -2.0 / 12.0); // 欢乐颂主旋律,简化版,一拍0.45秒 static const Note melody[] = { {E4, 0.45}, {E4, 0.45}, {F4, 0.45}, {G4, 0.45}, {G4, 0.45}, {F4, 0.45}, {E4, 0.45}, {D4, 0.45}, {C4, 0.45}, {C4, 0.45}, {D4, 0.45}, {E4, 0.45}, {E4, 0.675}, {D4, 0.45}, {D4, 0.90}, {E4, 0.45}, {E4, 0.45}, {F4, 0.45}, {G4, 0.45}, {G4, 0.45}, {F4, 0.45}, {E4, 0.45}, {D4, 0.45}, {C4, 0.45}, {C4, 0.45}, {D4, 0.45}, {E4, 0.45}, {D4, 0.90}, {C4, 0.45}, {C4, 0.90}, }; static const int NOTE_COUNT = sizeof(melody) / sizeof(melody[0]); int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); const int sampleRate = 22050; QByteArray pcm; for (int i = 0; i < NOTE_COUNT; ++i) { // 逐个音符拼接到缓冲区 appendTone(pcm, melody[i].freq, melody[i].seconds, sampleRate); } QAudioFormat fmt; fmt.setSampleRate(sampleRate); fmt.setChannelCount(1); fmt.setSampleSize(16); fmt.setCodec("audio/pcm"); fmt.setByteOrder(QAudioFormat::LittleEndian); fmt.setSampleType(QAudioFormat::SignedInt); QBuffer buffer(&pcm); buffer.open(QIODevice::ReadOnly); QAudioOutput *out = new QAudioOutput(fmt, &app); out->setVolume(0.7f); out->start(&buffer); // 按总时长+300ms尾部缓冲退出,避免截断最后一个音 double totalSeconds = 0.0; for (int i = 0; i < NOTE_COUNT; ++i) { totalSeconds += melody[i].seconds; } QTimer::singleShot(static_cast<int>((totalSeconds + 0.3) * 1000), &app, &QCoreApplication::quit); return app.exec(); }

采样率我固定用22050Hz。选这个值的原因:单音正弦波没有高频泛音,22050Hz的奈奎斯特频率足够表达,而且PCM数据量只有48000Hz的一半,在嵌入式设备上内存更友好。如果你的声卡在48000Hz才稳定工作,就把sampleRate改成48000,代码其它地方都不用动。

播放结束我故意没用stateChanged信号里的IdleState,因为QAudioOutput上报IdleState时,ALSA的硬件缓冲区里可能还残留着一部分采样,这时候退出进程会把最后一个音截断。直接按总时长+300ms用QTimer退出,是最稳妥的,这也是我自己踩过之后养成的习惯。

3.4 交叉编译与部署:qmake到开发板的最后一步

开发板通常和主机架构不同,需要进行交叉编译。假设你手头的工具链已经和Qt编译时的工具链一致,构建过程如下:

# 用交叉编译SDK里的qmake构建 $TOOLCHAIN_PATH/bin/qmake ode_to_joy.pro make # 上传到开发板,IP地址换成你板子的实际地址 scp ode_to_joy root@192.168.1.100:/usr/bin/ # 在板子上运行 /usr/bin/ode_to_joy

如果板子上没有Qt运行库,运行时会报错找不到libQt5Core.so这类文件。用ldd看一眼依赖:

ldd /usr/bin/ode_to_joy

把列出来的libQt5Core、libQt5Multimedia等库文件用scp传到开发板的/usr/lib目录下。这里要特别强调架构匹配:板子是32位ARM,就传32位的库,传错了直接segment fault。有的开发板出厂rootfs里已经带了Qt运行库,那这一步就能省掉,先跑ldd确认就行。

4. 五个必踩的坑:从没声音到音准翻车的排查顺序

这套方案我在不同板子上来回折腾过好几轮,踩坑的规律非常固定。按“先硬件、再系统、后代码”的顺序排查,能省下大量时间。

4.1 现象:程序跑了但完全没声音

排查第一步不是看代码,是确认硬件链路通不通。在板子上直接跑一个ALSA自带的测试播放命令:

aplay /usr/share/sounds/alsa/test.wav

如果这条命令也没声音,问题在系统层面,跟Qt没有任何关系。常见原因是ALSA通道被静音了,用alsamixer打开混音器,把Master和PCM通道的静音取消。

如果aplay能出声而Qt没声音,检查用户权限和ALSA设备占用情况。有些板子上/dev/snd的权限是660,当前用户不在audio组里就会打开设备失败。用root用户跑一次程序,能出声就是权限问题,把用户加进audio组即可。Qt的ALSA插件偶尔也会选错设备,设置环境变量QT_AUDIO_BACKEND=alsa强制后端,多半能解决问题。

4.2 现象:有声音但整体音调偏低或偏高

这是采样率不匹配的典型症状。代码里format.setSampleRate(22050),但声卡实际跑在48000Hz,数据播放速度就快了一倍多,音调明显偏高。反过来如果声卡实际跑16000Hz,音调就会偏低。

解决方法是查声卡的原生采样率:

cat /proc/asound/card0/pcm0p/sub0/hw_params

看里面的rate值,把代码里的sampleRate改成和声卡原生一致的数值。如果显示rate是48000,就把22050改成48000。很多codec支持多种采样率,但有一部分低成本codec只在固定采样率下工作正常,这就是音准翻车的根源。

4.3 现象:每次播放开头有一声“啪”的爆音

爆音来自波形起始和结束时刻的突变。正弦波从0相位开始理论上没有直流跳变,但音符和音符拼接时,前一个音符的结束点相位和后一个音符的起始点是错开的,听起来就是连续的“滴答”杂音。

我在appendTone里加的5ms淡入淡出包络就是专门治这个问题的。如果你的旋律里还有休止符,休止符要单独处理,不能直接跳过不写数据,而应该写入一段全零采样,否则设备缓冲区下溢。休止符时长一般和前后音符保持一致,全零采样最安全。

4.4 现象:Qt6工程编译报错,QAudioOutput的API对不上

Qt5和Qt6的音频API变化很大,不少人在升级工程时一脸懵。编译报错一般是“no matching function for call to QAudioOutput::QAudioOutput()”或者setCodec不存在。对照关系如下:

配置项Qt5写法Qt6写法
样本格式setSampleSize(16) + setSampleType(SignedInt)setSampleFormat(QAudioFormat::Int16)
编码格式setCodec("audio/pcm")不需要设置
输出设备QAudioDeviceInfo::defaultOutputDevice()QMediaDevices::defaultAudioOutput()
构造方式QAudioOutput(format, parent)QAudioOutput(device, parent)

Qt6里setCodec已经被移除,QAudioOutput的构造函数也改了参数。如果你的工具链是Qt6,把main函数里的格式配置部分替换成Qt6写法,逻辑不用动。

4.5 现象:用GPIO蜂鸣器方案,音调忽高忽低,音准是玄学

如果板子没有音频codec被迫走GPIO无源蜂鸣器,最经典的翻车点是试图用QTimer翻转GPIO产生方波。QTimer的精度只有毫秒级,而440Hz的方波需要每1.136毫秒翻转一次电平,QTimer的实际触发抖动会直接让音调漂移。

正解是单独起一个线程,用nanosleep做高精度延时。核心逻辑如下:

// 伪代码:独立线程中按频率翻转GPIO // halfPeriod = 1.0 / (2.0 * freq),440Hz对应0.001136秒 while (running) { writeGpio(1); nanosleep(&halfPeriod); writeGpio(0); nanosleep(&halfPeriod); }

如果板子的SoC自带PWM外设,优先用PWM生成方波,那才是正经的硬件级精度。QTimer做控制逻辑没问题,但用来生成音频方波就是拿错了工具。

5. 从欢乐颂到任意乐曲:简谱转数组和听感优化

欢乐颂跑通之后,你会忍不住想换别的曲子。手写音符数组太痛苦,我建议做一层转换脚本,把简谱字符串直接变成C数组。

#!/usr/bin/env python3 # 简谱转音符结构体,支持数字、附点、- 三种记号 scale = {'1': 'C4', '2': 'D4', '3': 'E4', '4': 'F4', '5': 'G4', '6': 'A4', '7': 'B4'} score = "3 3 4 5 | 5 4 3 2 | 1 1 2 3 | 3. 2 2 -" for item in score.replace('|', '').split(): if item == '-': print('{0, 0.90},') elif item.endswith('.'): print('{%s, 0.675},' % scale[item[0]]) else: print('{%s, 0.45},' % scale[item])

脚本输出的每一行直接粘贴到melody数组里就行。频率常量和整套播放代码完全复用,换一首歌只需要改一行简谱字符串。注意低音和高音记号,脚本里用大写的C4、D4表示中音区,高音可以在scale里继续加C5、D5。

关于听感,还有最后一个优化值得做:把简单的淡入淡出换成完整的ADSR包络。参数的经验值如下:

阶段时长建议听感效果
Attack5ms自然,接近钢琴起音
Decay20ms柔和,管乐感
Release30ms余音干净,不拖尾

单音旋律用ADSR包络之后,方波般的生硬感会弱很多,同一段欢乐颂听起来会从“电子门铃”变成“电子琴”。

我自己把这段旋律跑通之后最大的收获是养成了两个习惯:一是不管多简单的音频程序,PCM生成一律放后台线程完成,UI线程只负责触发,不然界面会卡顿;二是每个工程先跑通最小链路再优化,先用单音符确认设备出声,再上完整旋律。这样每一步出问题都能定位到具体环节。音频这条链路看不见摸不着,不按这个顺序来,等着你的就是反复改代码试运气。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询