嵌入式Linux下Qt播放PCM音频:用QAudioOutput在开发板上奏响欢乐颂
简介面向嵌入式开发与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.631D4293.662E4329.633F4349.234G4392.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 c11 TARGET ode_to_joy SOURCES main.cpp只有multimedia一个模块没有widgets因为播放旋律只需要QCoreApplication不需要界面。如果你的工程里还要显示乐谱或播放状态那就把QT widgets加上改成QApplication。工程文件里最容易被忽略的是CONFIG c11我的代码里用了std::pow和std::sinC标准不够会报错。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_castint(seconds * sampleRate); int fadeSamples sampleRate / 200; // 5ms淡入淡出防爆音 for (int i 0; i totalSamples; i) { double t static_castdouble(i) / sampleRate; // 16位PCM范围是-32768到32767振幅16000留了余量 short sample static_castshort(16000.0 * sin(2.0 * M_PI * freq * t)); if (i fadeSamples) { sample static_castshort(sample * i / fadeSamples); } else if (i totalSamples - fadeSamples) { sample static_castshort(sample * (totalSamples - i) / fadeSamples); } pcm.append(reinterpret_castconst 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大调音阶A4440Hz基准 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_castint((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 root192.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_BACKENDalsa强制后端多半能解决问题。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线程只负责触发不然界面会卡顿二是每个工程先跑通最小链路再优化先用单音符确认设备出声再上完整旋律。这样每一步出问题都能定位到具体环节。音频这条链路看不见摸不着不按这个顺序来等着你的就是反复改代码试运气。希望帮到你。本文还有配套的精品资源点击获取