一个吉他练习器需要实时发声。用户按和弦,音频输出不能有感知延迟。在鸿蒙上实现这一点,核心问题有两个:用什么音源,怎么把PCM送出去。本文拆解一套基于双音源架构的MIDI合成引擎,共1500行代码,包含AudioRenderer低延迟管线、SF2加载与管理、软合成振荡器、弦级voice跟踪、音频中断处理、静默自动暂停等模块。
一、双音源架构
引擎优先使用SF2 SoundFont采样,失败则回退到纯算法软合成。SF2模式采用MuseScore_General.sf2——一个25MB的GM音色库,包含128种MIDI预设,默认选中program 25(钢弦吉他)。软合成是算法生成,用加法合成器模拟吉他弦振动:基频正弦+5次泛音+指数衰减包络。选择SF2优先是因为其采样是真实录制的,包含拨弦瞬间的transient和自然衰减,算法合成难以还原。但SF2依赖25MB文件,若损坏或加载失败,软合成兜底保障应用不会静音。
二、SF2加载:从包内到沙箱
SF2文件不能直接从HAP包内随机访问,需先复制到沙箱。拷贝时使用64KB分块,因为rawfile的fd不支持seek,skipBytes需跳过offset才能读到实际数据。文件大于500KB才认为有效,防止拷贝中断产生残缺文件。
- // SoundfontBundledCopy.ets
- export async function copyBundledSf2IfNeeded(
- ctx: UIAbilityContext, destPath: string
- ): Promise<boolean> {
- try {
- if (fs.statSync(destPath).size > 500000) {
- return true;
- }
- } catch { }
- const raw = await ctx.resourceManager
- .getRawFileDescriptor('soundfont/MuseScore_General.sf2');
- const buf = new ArrayBuffer(65536);
- let left = raw.length;
- while (left > 0) {
- const n = fs.readSync(raw.fd, buf, { length: Math.min(65536, left) });
- fs.writeSync(dstFile.fd, buf.slice(0, n));
- left -= n;
- }
- }
复制代码
拷贝完成后,NAPI层的TinySoundFont库通过sfInit加载文件。加载失败则回退软合成。
- private tryInitSoundfont(): void {
- this.useSf = false;
- if (this.sf2Path.length < 1) return;
- const code = funvoice.sfInit(this.sf2Path);
- if (code === 0 && funvoice.sfIsReady() === 1) {
- this.useSf = true;
- return;
- }
- funvoice.sfShutdown();
- }
复制代码
`libfunvoice.so`是NAPI桥接库,封装了TinySoundFont的C++实现。sfInit、sfNoteOn、sfNoteOff、sfRenderPcmI16均为NAPI导出函数。SF2的采样数据在native层管理,ArkTS只发指令,不接触原始音频数据。
三、AudioRenderer低延迟管线
AudioRenderer是鸿蒙PCM输出的核心API。配置关键参数:
- const streamInfo: audio.AudioStreamInfo = {
- samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000,
- channels: audio.AudioChannel.CHANNEL_1,
- sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
- encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
- };
- const rendererInfo: audio.AudioRendererInfo = {
- content: audio.ContentType.CONTENT_TYPE_UNKNOWN,
- usage: audio.StreamUsage.STREAM_USAGE_RINGTONE,
- rendererFlags: this.lowLatency ? 1 : 0
- };
复制代码
四个关键选择:48kHz是鸿蒙AudioRenderer支持的最佳采样率;单声道减半带宽;S16LE与SF2输出格式一致,零拷贝;rendererFlags=1开启低延迟模式,AudioRenderer会使用更小的buffer。使用RINGTONE而非MUSIC类型,这是通过机审的基线策略。SHARE_MODE共享焦点,避免与其他音频应用互斥。
四、Pump循环:音频的心跳
启动AudioRenderer后,持续往buffer写PCM数据。pump是整个引擎的核心循环:
- private async runPump(): Promise<void> {
- const buf = new ArrayBuffer(this.bufferSize);
- const i16 = new Int16Array(buf);
- while (this.pumping) {
- let hasAudio = false;
- if (this.useSf) {
- const ab = funvoice.sfRenderPcmI16(nSamp);
- if (ab.byteLength === wantBytes) {
- hasAudio = InstrumentEngine.hasAudiblePcm(ab);
- await r.write(ab);
- } else {
- copySoundfontPcmToBuf(ab, buf, wantBytes);
- hasAudio = InstrumentEngine.hasAudiblePcm(buf);
- await r.write(buf);
- }
- } else {
- this.renderTo(i16);
- hasAudio = InstrumentEngine.hasAudiblePcm(buf);
- await r.write(buf);
- }
- if (hasAudio) {
- silentSinceMs = -1;
- } else {
- if (silentSinceMs < 0) silentSinceMs = Date.now();
- else if (Date.now() - silentSinceMs >= 1800) {
- idlePause = true;
- break;
- }
- }
- }
- }
复制代码
sfRenderPcmI16是NAPI调用,返回的ArrayBuffer可能直接写入AudioRenderer(零拷贝路径),也可能需要padding。pump循环退出条件:外部停止、write错误、连续1.8秒静默自动暂停。
五、软合成振荡器
SF2加载失败时回退到算法合成:
- private renderTo(i16: Int16Array): void {
- for (let i = 0; i < i16.length; i++) {
- let s = 0.0;
- for (let vi = 0; vi < this.voices.length; ) {
- const v = this.voices[vi];
- const f = 440.0 * Math.pow(2.0, (v.note - 69.0) / 12.0);
- const att = v.age < ATTACK_SAMPLES
- ? (v.age + 1) / ATTACK_SAMPLES : 1.0;
- v.age += 1;
- s += oscGuitar(v.phase, v.bright) * v.env * att;
- v.phase += TAU * f / SR;
- v.bright *= 0.99885;
- v.env *= 0.99952;
- if (v.env < 0.00012) {
- this.voices.splice(vi, 1);
- } else {
- vi += 1;
- }
- }
- const scaled = s * LOUDNESS_BOOST * 0.72;
- const c = Math.tanh(scaled) * GAIN;
- i16[i] = Math.max(-32768, Math.min(32767, Math.trunc(c)));
- }
- }
复制代码
oscGuitar模拟吉他弦振动:双弦微失谐(1.0013倍频偏移)产生自然chorus效果;亮度衰减使高频泛音衰减更快;tanh软限幅防止多voice叠加爆音。
六、弦级Voice跟踪
SF2模式下,每根弦独立跟踪noteOn/noteOff,同弦再拨时先off旧音再on新音,防止叠加“糊”。ringMs与BPM成正比:慢歌延音长,快歌延音短。
七、扫弦力度梯度
真实吉他扫弦时不同弦力度不同:下扫偏低音弦强,上扫偏高音弦强。力度范围控制在0.38~0.62之间,保持层次感。
八、音频中断处理
使用SHARE_MODE共享焦点,其他应用播放时暂停,对方结束后恢复。pauseByInterrupt同时停止演奏循环,恢复后重新启动。
九、静默自动暂停
连续1.8秒无有效音频(PCM绝对值>2)则暂停AudioRenderer节省功耗。下次有音符触发时自动重启。
十、踩坑记录
1. SF2 NAPI返回长度不匹配:不能假设等长,需检查byteLength并拷贝到固定buffer。
2. sfRenderPcmI16异常回退:catch中回退到软合成,无缝切换。
3. rendererReleasing幂等保护:使用布尔锁确保stop/release只调一次。
4. 低延迟模式buffer太小:getBufferSize()返回值可能很小,必须用实际值。
5. 清SF noteOff定时器:扫弦中断时清除所有待执行定时器,双保险。
十一、引擎参数一览
这套引擎核心思路是“够用就好”:SF2采样音色好但依赖文件,软合成音色差但零依赖,两者互补,通过AudioRenderer低延迟管线统一输出。1500行代码解决了“在鸿蒙上让吉他实时发声”的问题。 |