鸿蒙实战:音频波纹动画 —— 录制与播放双模式完整实现

源码已整理发布: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 数据中准确提取瞬时音量,并将其映射为视觉柱状高度 。我们使用的数据来自 AudioCapturerreadData 回调,每次返回一个 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) 是衡量信号能量的标准指标:

  1. 对每个样本求平方。
  2. 计算这些平方的平均值。
  3. 对平均值开平方根。

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 卡顿几秒 createAudioCapturerstart 耗时 立即更新 UI 状态,后台异步初始化
播放进度条结束时多出 1 秒 时长计算取整不一致(Math.floor vs 精确除法) 保存精确小数,播放时使用同一值

十二、总结

本文完整实现了一个鸿蒙音频波形应用,涵盖录音、播放、实时波形、进度控制、文件管理等核心模块。重点解决了以下技术难点:

  • 从 PCM 数据计算分贝并平滑;
  • 追加写入文件避免数据覆盖(最容易被忽视的坑);
  • 流式播放与 seek 实现;

所有代码均基于ArkTS开发,无第三方依赖。希望本文能帮助你在鸿蒙音频开发中少走弯路,快速构建富有交互感的音频应用。如果觉得本文对你有帮助,请点赞、收藏、转发支持!

相关推荐
坚果的博客14 小时前
Flutter 三方库(Flutter-New-Badge)适配开源鸿蒙教程
flutter·开源·harmonyos
名字不好奇14 小时前
华为τ定律如何颠覆摩尔定律
华为
不爱吃糖的程序媛15 小时前
鸿蒙Flutter 三方库 country_codes 的 适配实战
flutter·华为·harmonyos
●VON15 小时前
鸿蒙Flutter实战:Markdown编辑与预览实时切换
flutter·华为·harmonyos·鸿蒙
不羁的木木15 小时前
ArkUI实战演练04-状态管理与数据驱动
harmonyos
坚果的博客15 小时前
AtomCode 助力开源鸿蒙跨平台三方库生态共建
华为·开源·harmonyos
wechat_Neal15 小时前
华为花瓣地图海外版市场与技术对标分析报告
华为·汽车
lauo15 小时前
从华为“韬(τ)定律”到ibbot手机:AI原生时代的“τ”解法
华为·智能手机·ai-native
GLAB-Mary15 小时前
苏州华为培训哪家好?2026零基础华为HCIA/HCIP/HCIE指南
华为·华为认证·hcie·hcia·hcip