源码已整理发布:AudioWaveformDemo
基于 HarmonyOS 5.0+,实现录音实时波形、计时、保存 PCM 文件;播放时实时波形、可拖拽进度条。组件化封装,无第三方库。
音频应用中的实时波形反馈和进度控制是常见需求,但很多开发者并不了解如何从 PCM 数据中提取音量,也不知道如何高效绘制动态波纹,更不清楚如何实现流式播放与 Seek。本文将带你从零构建一个完整可运行的音频录制与播放器,包含以下核心能力:
- 录音:麦克风输入 → 实时计算分贝 → Canvas 柱状波纹 → 计时器 → 追加写入 PCM 文件
- 播放:选择录音 → 流式播放 → 实时波形 → 进度条(支持拖拽跳转)
- 文件管理:列表展示、删除、临时文件保护
- 权限管理 :动态申请麦克风权限 在
module.json5中添加ohos.permission.MICROPHONE权限 - UI设计 :主界面简洁,录音/播放共用底部面板(
bindSheet),波形复用
一、效果预览

二、项目架构与目录
entry/src/main/ets/
├── entryability/EntryAbility.ets // 应用入口,初始化单例
├── pages/Index.ets // 主界面(列表+录音按钮)
├── components/
│ ├── AudioSheet.ets // 统一底部面板(录音/播放)
│ ├── RecordingList.ets // 录音文件列表组件
│ └── WaveformView.ets // 波形绘制组件
├── managers/
│ ├── AudioCaptureManager.ets // 录音管理器
│ ├── AudioPlaybackManager.ets // 播放管理器
│ └── FileManager.ets // 文件操作(单例)
├── constants/AudioConstants.ets // 常量定义
├── utils/
│ ├── AudioMathUtil.ets // 分贝计算与平滑
│ └── PermissionUtil.ets // 权限申请
└── model/AudioFileInfo.ets // 录音文件信息模型
三、技术选型
| 能力 | API | 作用 |
|---|---|---|
| 音频录制 | @kit.AudioKit (AudioCapturer) |
获取麦克风 PCM 数据 |
| 音频播放 | @kit.AudioKit (AudioRenderer) |
播放 PCM 文件,支持进度控制 |
| 文件管理 | @kit.CoreFileKit |
PCM 文件读写、列表、删除 |
| 波纹绘制 | Canvas + @State/@Link |
实时柱状条,颜色渐变 |
| 权限管理 | @kit.AbilityKit |
动态申请麦克风权限 |
四、核心算法:从 PCM 数据计算分贝
音频波纹动画的灵魂是从原始 PCM 数据中准确提取瞬时音量,并将其映射为视觉柱状高度 。我们使用的数据来自 AudioCapturer 的 readData 回调,每次返回一个 ArrayBuffer,里面存放的是 16‑bit 线性脉冲编码调制(PCM)样本。每个样本是一个有符号整数,范围 -32768 ~ 32767,代表声波在该时刻的振幅。
4.1 归一化
为了后续计算不受设备或文件格式影响,先将整数样本转换成归一化的浮点值(范围 [-1.0, 1.0])。由于负数的最小值为 -32768,正数的最大值为 32767,严格归一化应分别处理:
- 负样本:
sample_float = sample_int / 32768.0(得到 -1.0) - 正样本:
sample_float = sample_int / 32767.0(得到 1.0)
但在实际工程中,为简化且避免条件分支,很多开发者统一除以 32768.0。此时正数最大映射为 32767/32768 ≈ 0.99997,与 1.0 的误差约 0.003%,对后续分贝计算的影响完全可以忽略。本文采用统一除以 32768.0 的简化写法:
s a m p l e f l o a t = s a m p l e i n t 32768.0 sample_{float} = \frac{sample_{int}}{32768.0} samplefloat=32768.0sampleint
4.2 计算均方根(RMS)
人耳感知的声音强度取决于一段时间内的平均能量,而非瞬时峰值。均方根(Root Mean Square) 是衡量信号能量的标准指标:
- 对每个样本求平方。
- 计算这些平方的平均值。
- 对平均值开平方根。
R M S = 1 N ∑ i = 1 N s a m p l e i 2 RMS = \sqrt{\frac{1}{N}\sum_{i=1}^{N} sample_i^2} RMS=N1i=1∑Nsamplei2
RMS 值也在 [0, 1] 之间,0 表示完全静音,1 表示最大不失真音量。
4.3 分贝转换
人耳对音量的感知是对数关系(韦伯‑费希纳定律),因此将 RMS 转换为分贝(dB):
d B = 20 ⋅ log 10 ( R M S + ϵ ) dB = 20 \cdot \log_{10}(RMS + \epsilon) dB=20⋅log10(RMS+ϵ)
- 系数 20 是因为我们用电压比计算分贝(声音信号幅度)。
- 加上极小值
ε = 1e-7避免log10(0)产生负无穷。
在数字音频中,0 dB 对应 RMS = 1(最大音量),静音的理论值是 -∞。实际应用中我们只关心 -60 dB ~ 0 dB 的范围,因为低于 -60 dB 的声音人耳几乎无法察觉,UI 上可视为静音。
javascript
db = Math.min(0, Math.max(-60, db));
4.4 映射到百分比
为了直接控制柱状条的高度,我们将 [-60, 0] 线性映射到 [0, 100]:
p e r c e n t = d b + 60 60 × 100 percent = \frac{db + 60}{60} \times 100 percent=60db+60×100
4.5 指数平滑(让波纹更柔顺)
音频数据变化极快(每秒几十次回调),若直接用原始百分比刷新柱状条,波形会剧烈抖动,视觉上很不自然。引入指数移动平均(EMA) 可以大幅降低抖动,同时保持基本跟随:
s m o o t h e d n e w = s m o o t h e d o l d ⋅ f a c t o r + c u r r e n t ⋅ ( 1 − f a c t o r ) smoothed_{new} = smoothed_{old} \cdot factor + current \cdot (1 - factor) smoothednew=smoothedold⋅factor+current⋅(1−factor)
factor 越接近 1,波纹越"迟钝",越平滑;默认使用 0.6,在响应速度和视觉舒适度间取得良好平衡。
4.6 完整实现
javascript
// utils/AudioMathUtil.ets
export class AudioMathUtil {
static calculateDecibels(buffer: ArrayBuffer): number {
const int16Array = new Int16Array(buffer);
let sumSquares = 0;
for (let i = 0; i < int16Array.length; i++) {
// 统一除以 32768.0 简化处理,理论误差可忽略
const sample = int16Array[i] / 32768.0;
sumSquares += sample * sample;
}
const rms = Math.sqrt(sumSquares / int16Array.length);
let db = 20 * Math.log10(rms + 1e-7);
db = Math.min(0, Math.max(-60, db));
return (db + 60) / 60 * 100;
}
static smooth(prev: number, cur: number, factor = 0.6): number {
return prev * factor + cur * (1 - factor);
}
}
4.7 数值对应关系
| RMS | dB | 百分比 | 视觉感受 |
|---|---|---|---|
| 1.0 | 0 | 100 | 满格红色,最大音量 |
| 0.1 | -20 | 66.7 | 中等偏上,黄绿色 |
| 0.01 | -40 | 33.3 | 较低,浅绿色 |
| 0.001 | -60 | 0 | 几乎无波纹(静音) |
五、文件管理:追加写入与临时文件
最大的坑 :如果不使用 APPEND 标志,每次 writeSync 会从文件开头覆盖,导致最终文件只保留最后一次回调的数据(约 1920 字节),时长永远为 0。
解决方案 :打开文件时增加 OpenMode.APPEND。同时,录音先写入临时文件(temp_xxx.pcm),成功后再重命名为正式文件,避免录音中断时残留损坏文件。
javascript
// managers/FileManager.ets 片段
async appendPCMData(filePath: string, data: ArrayBuffer): Promise<void> {
const file = fileIo.openSync(filePath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.APPEND);
try {
fileIo.writeSync(file.fd, data);
} finally {
fileIo.closeSync(file);
}
}
六、录音管理器:实时写入与波形回调
AudioCaptureManager 核心职责:
- 创建并启动
AudioCapturer。 - 注册
readData回调,每收到一段 PCM 数据就:- 调用
AudioMathUtil.calculateDecibels计算音量百分比。 - 通过回调函数通知 UI 层更新波形。
- 将原始数据追加写入临时文件。
- 调用
- 停止时释放资源。
注意 :必须调用
setFileManager注入文件管理器,否则不会保存文件。
javascript
import { AudioConstants } from '../constants/AudioConstants';
import { AudioMathUtil } from '../utils/AudioMathUtil';
import { FileManager } from './FileManager';
import { audio } from '@kit.AudioKit';
export class AudioCaptureManager {
private audioCapturer: audio.AudioCapturer | null = null;
private isCapturing: boolean = false;
private onAmplitudeCallback: (percent: number) => void = () => {};
private fileManager?: FileManager;
currentFilePath: string = '';
private isPrepared: boolean = false;
setFileManager(manager: FileManager) {
this.fileManager = manager;
}
setOnAmplitudeChange(callback: (percent: number) => void) {
this.onAmplitudeCallback = callback;
}
/**
* 提前创建 AudioCapturer 并注册回调,避免首次启动卡顿
* 建议在页面 aboutToAppear 中调用
*/
async prepare(): Promise<void> {
if (this.audioCapturer) return;
try {
const capturerOptions: audio.AudioCapturerOptions = {
streamInfo: {
samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_48000, // 48000 Hz 更通用
channels: AudioConstants.CHANNELS,
sampleFormat: AudioConstants.SAMPLE_FORMAT,
encodingType: AudioConstants.ENCODING_TYPE
},
capturerInfo: {
source: audio.SourceType.SOURCE_TYPE_MIC,
capturerFlags: 0 // 避免异常标志
}
};
this.audioCapturer = await audio.createAudioCapturer(capturerOptions);
this.isPrepared = true;
console.info('AudioCapturer prepared successfully');
} catch (err) {
console.error('AudioCapturer prepare failed', JSON.stringify(err));
throw new Error(err);
}
}
/**
* 开始录音
* @param filePath 保存 PCM 数据的临时文件路径
*/
async startRecording(filePath: string): Promise<void> {
if (this.isCapturing) {
throw new Error('Already recording');
}
// 如果没有预热或已经释放,则创建新实例
if (!this.audioCapturer) {
await this.prepare();
}
this.currentFilePath = filePath;
// 注册 readData 回调(必须在 start 之前注册)
if (this.audioCapturer) {
let lastAmplitude = 50;
// 移除旧监听避免重复
this.audioCapturer.off('readData');
this.audioCapturer.on('readData', async (buffer: ArrayBuffer) => {
console.info(`readData triggered, size=${buffer.byteLength}`);
if (!this.isCapturing) return;
const raw = AudioMathUtil.calculateDecibels(buffer);
lastAmplitude = AudioMathUtil.smooth(lastAmplitude, raw);
this.onAmplitudeCallback(lastAmplitude);
if (this.fileManager && this.currentFilePath) {
try {
await this.fileManager.appendPCMData(this.currentFilePath, buffer);
} catch (writeErr) {
console.error('appendPCMData failed', writeErr);
}
}
});
}
// 启动录制
try {
await this.audioCapturer!.start();
this.isCapturing = true;
console.info('AudioCapturer started');
} catch (err) {
console.error('AudioCapturer start failed', JSON.stringify(err));
throw new Error(err);
}
}
/**
* 停止录音并释放资源
*/
async stopRecording(): Promise<void> {
if (!this.isCapturing || !this.audioCapturer) return;
this.isCapturing = false;
try {
await this.audioCapturer.stop();
console.info('AudioCapturer stopped');
} catch (err) {
console.error('stop error', JSON.stringify(err));
} finally {
// 释放资源,下次录音需重新 prepare
await this.release();
}
}
/**
* 完全释放 AudioCapturer(停止后调用)
*/
async release(): Promise<void> {
if (this.audioCapturer) {
try {
this.audioCapturer.off('readData');
await this.audioCapturer.release();
} catch (err) {
console.error('release error', JSON.stringify(err));
}
this.audioCapturer = null;
this.isPrepared = false;
}
}
/**
* 获取当前是否正在录音
*/
get isActive(): boolean {
return this.isCapturing;
}
}
七、播放管理器:流式播放与进度控制
AudioPlaybackManager 将整个 PCM 文件读入内存(适合短音频),然后分段写入 AudioRenderer。通过 setTimeout 循环写入,同时:
- 每次写入前计算分贝并触发波形更新。
- 基于真实时间流逝更新
currentPositionMs,驱动进度条。 - 支持
seekTo:用户拖拽进度条时设置seekTargetMs,循环中重新定位字节偏移。
javascript
import { AudioConstants } from '../constants/AudioConstants';
import { AudioMathUtil } from '../utils/AudioMathUtil';
import { audio } from '@kit.AudioKit';
export class AudioPlaybackManager {
private audioRenderer: audio.AudioRenderer | null = null;
private isPlaying: boolean = false;
private onAmplitudeCallback: (percent: number) => void = () => {};
private onProgressCallback: (currentMs: number, totalMs: number) => void = () => {};
private pcmData: ArrayBuffer | null = null;
private totalDurationMs: number = 0;
private currentPositionMs: number = 0;
private sampleRate: number = 44100;
private channels: number = 1;
private bytesPerSample: number = 2;
private playIntervalId: number = -1;
private seekTargetMs: number = -1;
setOnAmplitudeChange(callback: (percent: number) => void) {
this.onAmplitudeCallback = callback;
}
setOnProgress(callback: (currentMs: number, totalMs: number) => void) {
this.onProgressCallback = callback;
}
async startPlayback(pcmData: ArrayBuffer, sampleRate: number = 44100, channels: number = 1): Promise<void> {
if (this.isPlaying) await this.stopPlayback();
this.pcmData = pcmData;
this.sampleRate = sampleRate;
this.channels = channels;
const totalSamples = pcmData.byteLength / (this.bytesPerSample * channels);
this.totalDurationMs = (totalSamples / sampleRate) * 1000;
this.currentPositionMs = 0;
this.seekTargetMs = -1;
const rendererOptions: audio.AudioRendererOptions = {
streamInfo: {
samplingRate: sampleRate,
channels: channels,
sampleFormat: AudioConstants.SAMPLE_FORMAT,
encodingType: AudioConstants.ENCODING_TYPE
},
rendererInfo: {
content: audio.ContentType.CONTENT_TYPE_SPEECH,
usage: audio.StreamUsage.STREAM_USAGE_VOICE_COMMUNICATION,
rendererFlags: 0
}
};
this.audioRenderer = await audio.createAudioRenderer(rendererOptions);
await this.audioRenderer.start();
this.isPlaying = true;
this.startPlaybackLoop();
}
private startPlaybackLoop() {
let position = 0; // bytes
let lastTimestamp = Date.now();
const chunkSize = AudioConstants.BUFFER_SIZE;
const playNext = async () => {
if (!this.isPlaying || !this.audioRenderer || !this.pcmData) return;
if (this.seekTargetMs !== -1) {
const newBytePos = this.seekTargetMs * (this.sampleRate * this.bytesPerSample * this.channels) / 1000;
position = Math.min(Math.max(0, newBytePos), this.pcmData.byteLength);
this.currentPositionMs = this.seekTargetMs;
this.seekTargetMs = -1;
lastTimestamp = Date.now();
this.onProgressCallback(this.currentPositionMs, this.totalDurationMs);
}
if (position >= this.pcmData.byteLength) {
await this.stopPlayback();
return;
}
const end = Math.min(position + chunkSize, this.pcmData.byteLength);
const chunk = this.pcmData.slice(position, end);
const percent = AudioMathUtil.calculateDecibels(chunk);
this.onAmplitudeCallback(percent);
await this.audioRenderer.write(chunk);
position = end;
const now = Date.now();
const elapsed = now - lastTimestamp;
this.currentPositionMs += elapsed;
if (this.currentPositionMs > this.totalDurationMs) this.currentPositionMs = this.totalDurationMs;
this.onProgressCallback(this.currentPositionMs, this.totalDurationMs);
lastTimestamp = now;
if (this.isPlaying) {
this.playIntervalId = setTimeout(() => {
playNext()
}, 10);
}
};
playNext();
}
async seekTo(positionMs: number): Promise<void> {
if (!this.pcmData) return;
this.seekTargetMs = Math.min(Math.max(0, positionMs), this.totalDurationMs);
if (!this.isPlaying) {
this.currentPositionMs = this.seekTargetMs;
this.onProgressCallback(this.currentPositionMs, this.totalDurationMs);
this.seekTargetMs = -1;
}
}
async stopPlayback(): Promise<void> {
if (this.playIntervalId !== -1) {
clearTimeout(this.playIntervalId);
this.playIntervalId = -1;
}
if (!this.isPlaying || !this.audioRenderer) return;
this.isPlaying = false;
await this.audioRenderer.stop();
await this.audioRenderer.release();
this.audioRenderer = null;
this.pcmData = null;
this.currentPositionMs = 0;
}
}
八、波形组件:柱状条与颜色渐变
WaveformView 接收 amplitudes 数组(长度固定为 40),每次数组内容变化时重绘 Canvas。柱状高度按百分比映射,颜色采用 RGBA :低音量 → 绿色,高音量 → 红色。
javascript
private draw() {
if (!this.ctx || this.canvasWidth === 0 || this.canvasHeight === 0) return;
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
if (!this.amplitudes || this.amplitudes.length === 0) return;
const barWidth = this.canvasWidth / this.amplitudes.length;
for (let i = 0; i < this.amplitudes.length; i++) {
const heightPercent = this.amplitudes[i] / 100;
const barHeight = Math.max(2, heightPercent * this.canvasHeight);
const x = i * barWidth;
const y = this.canvasHeight - barHeight;
// 使用 RGBA 渐变:低音量绿色 (R=0,G=255),高音量红色 (R=255,G=0)
const red = Math.floor(255 * heightPercent);
const green = Math.floor(255 * (1 - heightPercent));
this.ctx.fillStyle = `rgba(${red}, ${green}, 0, 1)`;
this.ctx.fillRect(x, y, barWidth - 2, barHeight);
}
}
九、统一面板 AudioSheet:录音与播放复用
通过 mode 属性区分录音/播放,内部逻辑和 UI 分离,波形组件共用。
- 录音模式:显示计时文本和「结束录音」按钮。
- 播放模式:显示进度条(Slider)、当前/总时长、「暂停」「关闭」按钮。
@Link isOpen 实现与父组件的双向绑定,关闭面板时同步状态。
十、主界面 Index:列表 + 录音按钮 + 弹窗绑定
RecordingList 组件负责渲染列表项(文件名、时长、播放/删除按钮)。底部按钮控制录音面板的打开。
bindSheet 绑定 $$this.isSheetOpen,并传入 AudioSheetBuilder。在 onDisappear 中根据模式刷新列表或停止播放。
javascript
.bindSheet($$this.isSheetOpen, this.AudioSheetBuilder, {
detents: [SheetSize.MEDIUM],
showClose: true,
dragBar: true,
onDisappear: () => {
if (this.sheetMode === 'recording') {
this.loadRecordings();
this.isPlaying = false;
this.currentPlayingFile = null;
} else if (this.sheetMode === 'playing') {
this.stopPlayback();
}
this.selectedFile = undefined;
}
})
@Builder
AudioSheetBuilder() {
AudioSheet({
isOpen:this.isSheetOpen,
mode: this.sheetMode,
file: this.sheetMode === 'playing' ? this.selectedFile : undefined
})
}
十一、关键踩坑总结
| 问题现象 | 原因 | 解决方案 |
|---|---|---|
| 录音文件只有 1920 字节,时长 0 | 未使用 APPEND 标志,每次写入覆盖文件 |
打开文件时增加 OpenMode.APPEND |
| 录音结束重命名失败(13900002) | 临时文件未写入任何数据(readData 未触发或未注入 FileManager) |
检查权限、真机测试、调用 captureManager.setFileManager |
| 录音时 UI 卡顿几秒 | createAudioCapturer 和 start 耗时 |
立即更新 UI 状态,后台异步初始化 |
| 播放进度条结束时多出 1 秒 | 时长计算取整不一致(Math.floor vs 精确除法) |
保存精确小数,播放时使用同一值 |
十二、总结
本文完整实现了一个鸿蒙音频波形应用,涵盖录音、播放、实时波形、进度控制、文件管理等核心模块。重点解决了以下技术难点:
- 从 PCM 数据计算分贝并平滑;
- 追加写入文件避免数据覆盖(最容易被忽视的坑);
- 流式播放与 seek 实现;
所有代码均基于ArkTS开发,无第三方依赖。希望本文能帮助你在鸿蒙音频开发中少走弯路,快速构建富有交互感的音频应用。如果觉得本文对你有帮助,请点赞、收藏、转发支持!