HarmonyOS 音频实战:用 AVPlayer 播放本地生成的五音 WAV

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1. 需求背景

“律愈”的疗愈页需要根据五音播放不同音频。项目当前采用一个适合原型验证的方案:

  1. 根据五音元数据读取频率。
  2. 在本地生成 16bit PCM WAV。
  3. 写入应用缓存目录。
  4. 使用 AVPlayer 播放。
  5. 播放完成后 seek 到 0,实现循环。

2. 五音频率来自数据模型

WuYinData.ets 中,每个音色都有 frequencyHz

export interface WuYinMeta {
  key: ToneKey;
  name: string;
  frequencyHz: number;
  color: string;
  glow: string;
}

播放时通过:

const hz: number = wuYinMetaOf(tone).frequencyHz;

这说明音频服务不需要自己维护五音表,它只消费领域模型。

3. WAV 生成核心

项目用 ArrayBuffer 手动写 WAV 头和 PCM 数据:

const SAMPLE_RATE: number = 44100;
const TONE_WAV_SECONDS: number = 6;

function buildSineWavBuffer(freqHz: number, durationSec: number, sampleRate: number): ArrayBuffer {
  const numSamples: number = Math.floor(sampleRate * durationSec);
  const dataSize: number = numSamples * 2;
  const buffer: ArrayBuffer = new ArrayBuffer(44 + dataSize);
  const v: DataView = new DataView(buffer);

  const w = (off: number, s: string): void => {
    for (let i = 0; i < s.length; i++) {
      v.setUint8(off + i, s.charCodeAt(i));
    }
  };

  w(0, 'RIFF');
  v.setUint32(4, 36 + dataSize, true);
  w(8, 'WAVE');
  w(12, 'fmt ');
  v.setUint32(16, 16, true);
  v.setUint16(20, 1, true);
  v.setUint16(22, 1, true);
  v.setUint32(24, sampleRate, true);
  v.setUint32(28, sampleRate * 2, true);
  v.setUint16(32, 2, true);
  v.setUint16(34, 16, true);
  w(36, 'data');
  v.setUint32(40, dataSize, true);

  let off: number = 44;
  const amp: number = 0.18 * 32767;
  for (let i = 0; i < numSamples; i++) {
    const t: number = i / sampleRate;
    const edge: number = Math.min(1, i / 2000, (numSamples - i) / 2000);
    const s: number = Math.sin(2 * Math.PI * freqHz * t) * amp * edge;
    v.setInt16(off, Math.round(s), true);
    off += 2;
  }
  return buffer;
}

这里的 edge 是一个很重要的小细节:它给音频开头和结尾做了淡入淡出,避免突然开始或停止造成明显爆音。

4. 写入缓存目录

生成后的 WAV 会落到应用缓存:

private wavPathFor(tone: ToneKey): string {
  return `${this.ctx.cacheDir}/yulv_tone_${tone}.wav`;
}

如果文件不存在,则生成并写入:

private async ensureWavOnDisk(tone: ToneKey): Promise<string> {
  const path: string = this.wavPathFor(tone);
  let exists: boolean = false;
  try {
    fs.statSync(path);
    exists = true;
  } catch (_e) {
    exists = false;
  }

  if (!exists) {
    const hz: number = wuYinMetaOf(tone).frequencyHz;
    const buf: ArrayBuffer = buildSineWavBuffer(hz, TONE_WAV_SECONDS, SAMPLE_RATE);
    const out: fs.File = await fs.open(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    try {
      await fs.write(out.fd, buf);
    } finally {
      await fs.close(out);
    }
  }
  return path;
}

这段代码的设计目标是:每个音色只生成一次,后续直接复用缓存文件。

5. AVPlayer 状态机

AVPlayer 不是设置源后马上就能播放,它要经历状态变化。项目中监听了 stateChange

private async ensurePlayer(): Promise<media.AVPlayer> {
  if (this.avPlayer !== null) {
    return this.avPlayer;
  }
  const p: media.AVPlayer = await media.createAVPlayer();
  p.on('stateChange', async (state: media.AVPlayerState): Promise<void> => {
    if (this.avPlayer === null) {
      return;
    }
    const pl: media.AVPlayer = this.avPlayer;
    if (state === 'initialized') {
      await pl.prepare();
      return;
    }
    if (state === 'prepared') {
      if (this.playAfterPrepared) {
        this.playAfterPrepared = false;
        pl.setVolume(0.55);
        await pl.play();
      }
      return;
    }
    if (state === 'completed') {
      await pl.seek(0, media.SeekMode.SEEK_CLOSEST);
      await pl.play();
    }
  });
  this.avPlayer = p;
  return p;
}

这个状态机解决了两个问题:

  • 设置 fdSrc 后等待 prepared 再播放。
  • 播放完成后重新回到开头,实现循环疗愈音。

6. 加载音色

加载时先确保 WAV 存在,再 reset 旧播放器状态,最后设置 fdSrc

async loadTone(tone: ToneKey): Promise<void> {
  const path: string = await this.ensureWavOnDisk(tone);
  const player: media.AVPlayer = await this.ensurePlayer();
  if (this.loadedTone === tone && this.audioFile !== null) {
    return;
  }
  if (player.state !== 'idle' && player.state !== 'released') {
    await player.reset();
  }
  await this.closeFile();
  this.audioFile = await fs.open(path, fs.OpenMode.READ_ONLY);
  player.fdSrc = { fd: this.audioFile.fd };
  this.loadedTone = tone;
  await this.waitPreparedAfterFdSrc();
}

注意 closeFile() 也很重要,切换音色时如果不关闭旧文件句柄,长期使用会产生资源泄漏。

7. 播放流程图

playTone tone

ensureWavOnDisk

ensurePlayer

reset old state

open wav file

set fdSrc

initialized

prepare

prepared

play

completed

seek 0 and play again

8. 后续优化方向

当前方案适合原型和演示,正式产品可以继续优化:

  • 用真实录制或制作的疗愈音轨替换正弦波。
  • 加入淡入淡出控制,而不是只在 WAV 两端处理。
  • 增加音量、白噪声、自然声叠加。
  • 增加错误态 UI,让用户知道是权限、文件还是播放器状态问题。

9. 小结

这套音频实现的关键不是“生成一个声音”,而是把 HarmonyOS 音频播放链路跑通:缓存文件、文件句柄、AVPlayer 状态机、循环播放、资源释放。

对于学习 HarmonyOS 音频开发的人来说,这比直接播放一个固定 mp3 更能理解底层流程。

9. WAV 头字段拆解

项目手写 WAV 的好处是能看清音频文件结构。一个最小可播放 WAV 由 44 字节头部和 PCM 数据组成:

ext RIFF chunk WAVE format fmt subchunk data subchunk PCM samples

代码里这些字段是关键:

s w(0, 'RIFF'); v.setUint32(4, 36 + dataSize, true); w(8, 'WAVE'); w(12, 'fmt '); v.setUint16(20, 1, true); // PCM v.setUint16(22, 1, true); // mono v.setUint32(24, sampleRate, true); v.setUint16(34, 16, true); // 16 bit w(36, 'data');

10. 为什么要缓存 WAV

如果每次播放都重新生成 WAV,会造成不必要的 CPU 和 IO 开销。项目用 cacheDir 做一次性生成:

s private wavPathFor(tone: ToneKey): string { return ${this.ctx.cacheDir}/yulv_tone_.wav; }

这意味着每个音色最多生成一个文件。后续切换回来时,只需要 s.open()。

11. AVPlayer 状态机踩坑

AVPlayer 的常见错误是设置源后立即播放。正确流程应该是:

ext createAVPlayer set fdSrc initialized prepare prepared play

项目中用 playAfterPrepared 解决了“用户点击播放时播放器还没 prepared”的问题:

` s
private playAfterPrepared: boolean = false;

if (state === ‘prepared’) {
if (this.playAfterPrepared) {
this.playAfterPrepared = false;
pl.setVolume(0.55);
await pl.play();
}
}

12. 资源释放不能省

音频类代码最容易遗漏文件句柄和播放器释放:

s async release(): Promise<void> { this.playAfterPrepared = false; await this.pause(); await this.closeFile(); if (this.avPlayer !== null) { await this.avPlayer.release(); this.avPlayer = null; } await this.backgroundAudio.release(); this.loadedTone = null; }

13. 从原型到正式版的替换路径

当前正弦波适合演示,但正式版本可以替换为真实音轨:

s interface ToneAudioSource { tone: ToneKey; assetPath: string; durationSec: number; loop: boolean; }

这样 ToneAudioPlayer 的状态机仍然可以复用,只替换 ensureWavOnDisk() 这一层。

g;
durationSec: number;
loop: boolean;
}

这样 ToneAudioPlayer 的状态机仍然可以复用,只替换 ensureWavOnDisk() 这一层。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐