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



1. 需求背景
“律愈”的疗愈页需要根据五音播放不同音频。项目当前采用一个适合原型验证的方案:
- 根据五音元数据读取频率。
- 在本地生成 16bit PCM WAV。
- 写入应用缓存目录。
- 使用 AVPlayer 播放。
- 播放完成后 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. 播放流程图
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() 这一层。
更多推荐



所有评论(0)