【harmonyOS NEXT 下的前端开发者】WAV音频编码实现

1,424 阅读7分钟

继 6 年前使用 js 实现的 mp4 封装之后,再次回顾编解码的知识是在23年8月接收到的私信,让补充下插件里的音频部分。

image.png 被迫回去翻了一下6年前的代码,然而发现当初提交的也没有音频的部分,而由于时间久远,早已忘记的差不多了,没能力赚这笔外快了。视频编码部分还是因为有保留的代码支持,才能捡回来一些。

背景

原文 js实现封装MP4格式文件并下载 中,因为近几年的技术更新与变化,一些重要的资料网站也被关停了。然而,我现在又要开始做音视频编码了。

作为一名前端开发者,我现在需要在鸿蒙 5.0 下实现音频编码。关于为什么不说编解码,因为目前的需求,收到的音频数据流是 PCM 这样的基础格式,可以通过编码封装为各种其他的如 wav、mp3、aac 等其他可进行播放的音频格式。

音频常见格式

对音频进行编码常见的格式有:

格式类型说明
PCM无压缩一种将模拟信号的数字化方法,无损编码。
WAV无压缩有多种实现方式,但是都不会进行压缩操作。其中一种实现就是在 PCM 数据格式的前面加上 44 字节,分别用来描述 PCM 的采样率、声道数、数据格式等信息。音质非常好,大量软件都支持。
MP3有损压缩音质在 128 Kbps 以上表现还不错,压缩比比较高,大量软件和硬件都支持,兼容性好。
AAC有损压缩在小于 128 Kbps 的码率下表现优异,并且多用于视频中的音频编码。
OPUS有损压缩可以用比 MP3 更小的码率实现比 MP3 更好的音质,高中低码率下均有良好的表现,兼容性不够好,流媒体特性不支持。适用于语音聊天的音频消息场景。

PCM 是音频原始数据的基础格式,并不支持直接用于播放,但可以将其通过编码转换成其他支持播放的格式文件,也可将一些格式文件解码成PCM后再进行编码来实现不同格式的音频文件转换;AAC 则在短视频和直播场景广泛使用。

我们能直接获取到的默认音频格式为音频裸数据格式,即 PCM 格式,因此并不需要对 PCM 进行额外的编码,但需要获取到对应的配置,用于将 PCM 格式的音频转换为其他格式的音频,比如我们本文中的 WAV 格式。

WAV

WAV 全称 Waveform Audio File Format,是微软公司开发的一种无损声音文件格式,也叫波形声音文件,是最早的数字音频格式,被 Windows 平台及其应用程序广泛支持。

WAV 符合 RIFF(Resource Interchange File Format) 规范,所有的WAV都由 44字节 文件头 和 PCM 数据 组成,这个文件头包含语音信号的所有参数信息(声道数、采样率、量化位数、比特率....) 44个字节的 头文件由 3个区块组成:

  • RIFF chunk:WAV文件标识
  • Format chunk: 声道数、采样率、量化位数、等信息
  • Data chunk:存放 PCM 数据

image.png

根据上图,所有区块的内容如下:

RIFF 区块

名称字节数内容描述
ID4Byte'RIFF'RIFF标识
Size4BytefileSize - 8整个文件的长度减去IDSize的长度
Type4Byte'WAVE'WAVE 格式类型。表示后面需要两个子块:Format区块和Data区块

format 区块

名称字节数内容描述
ID4Byte'fmt 'fmt 标识
Size4Byte16区块长度 fmt (不包含IDSize 部分的长度)
AudioFormat2Byte音频格式PCM = 1(即线性量化),1 以外的值表示一些压缩形式。
NumChannels2Byte声道数音频数据的声道数,1:单声道,2:双声道
SampleRate4Byte采样率音频数据的采样率
ByteRate4Byte每秒数据字节数音频数据的码率:采样率 * 通道数 * 位深 / 8
BlockAlign2Byte数据块对齐一个样本的字节数:通道数 * 位深 / 8
BitsPerSample2Byte采样位数位深:8 位 = 8,16 位 = 16,等等

data 区块

名称字节数内容描述
ID4Byte'data'data标识
Size4Byte音频数据长度音频数据长度,一般为ByteRate * 时间(s),若为文件,则可直接取文件长度
DataNByte音频数据音频数据内容

根据上述的WAV 格式标准,我们就可以在鸿蒙上实现一个将 PCM 格式文件编码成 WAV 格式文件的功能函数了

实现

WAV 文件格式还是比较清晰的,因此实现上也比较简单,将 PCM 文件的内容读取出来,按照上述的格式,在 WAV 文件中写入文件头,再将 PCM 文件内容续写到 WAV 文件中即可完成 PCM 到 WAV 格式的音频文件转换。

由于鸿蒙 ArkTS 对前端的友好性,对于前端来说,实现上也变得更加简单。

import fs, { ReadOptions } from '@ohos.file.fs';
export class PcmToWavUtil {
  //采样率
  private mSampleRate: number = 0
  // 声道数
  private mChannel: number = 0;
  
  /**
   * @param sampleRate  sample rate、采样率
   * @param channel     channel、声道
   */
  constructor(sampleRate:number, channel: number) {
    this.mSampleRate = sampleRate;
    this.mChannel = channel;
  }

  /**
   * pcm文件转wav文件函数
   * @param src  源文件
   * @param dest 目标文件
   */
  public pcmToWav(src: string, dest: string) {
    const inFile: fs.File = fs.openSync(src, fs.OpenMode.READ_ONLY);
    const outFile: fs.File = fs.openSync(dest, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    let byteRate = 16 * this.mSampleRate * this.mChannel / 8;
    const inFileStat = fs.statSync(inFile.fd)
    // 音频文件
    let audioDataSize = inFileStat.size;
    let totalDataLen = audioDataSize + 36;
    // 1. wav 文件头编写
    this.writeWaveFileHeader(outFile, audioDataSize, totalDataLen, byteRate);
    // 2. 写入 pcm 数据
    this.writePcmData(inFile, outFile, audioDataSize)
  }

  // pcm 数据写入到 pcm 文件函数
  private writePcmData(inFile: fs.File, outFile: fs.File, audioDataSize: number) {
    // 写入 pcm 数据
    let readSize = 0
    let data = new ArrayBuffer(audioDataSize);
    let readOptions: ReadOptions = {
      offset: readSize,
      length: audioDataSize
    };
    let readLen = fs.readSync(inFile.fd, data, readOptions);
    while (readLen > 0) {
      readSize += readLen;
      fs.writeSync(outFile.fd, data, { length: readLen});
      readOptions.offset = readSize;
      readLen = fs.readSync(inFile.fd, data, readOptions);
    }
    fs.closeSync(inFile.fd)
    fs.closeSync(outFile.fd)
  }
  
  //写入wav文件头函数
  private writeWaveFileHeader(
    out: fs.File,
    audioDataSize: number,
    totalDataLen: number,
    byteRate: number
  ) {
    const header = new ArrayBuffer(44);
    const dv = new DataView(header);
    const bitsPerSample = 16; // 当前位深是16

    // 写入RIFF块
    this.writeString(dv, 0, 'RIFF');
    dv.setUint32(4, totalDataLen, true);
    this.writeString(dv, 8, 'WAVE');

    // 写入fmt块
    this.writeString(dv, 12, 'fmt ');
    dv.setUint32(16, 16, true); // fmt块大小
    dv.setUint16(20, 1, true); // 格式类别 (PCM)
    dv.setUint16(22, this.mChannel, true); // 通道数
    dv.setUint32(24, this.mSampleRate, true); // 采样率
    dv.setUint32(28, byteRate, true); // ByteRate 码率
    dv.setUint16(32, this.mChannel * bitsPerSample / 8, true); // BlockAlign
    dv.setUint16(34, bitsPerSample, true); // 位深

    // 写入data块
    this.writeString(dv, 36, 'data');
    dv.setUint32(40, audioDataSize, true); // 数据块大小
    // 写入
    fs.writeSync(out.fd, new Uint8Array(header).buffer, {
      length: 44
    })
  }

  private writeString(dv: DataView, offset: number, str: string) {
    for (let i = 0; i < str.length; i++) {
      dv.setUint8(offset + i, str.charCodeAt(i));
    }
  }
}

最后

如今随着 ffmpeg 的发展,已经可以实现各种音视频的直接转换,能够直接输入一个音视频文件,通过一系列的指令和参数,实现一键粗暴生成想要的音视频格式了。但站在开发者的角度,从很多方面考虑,在能使用ffmpeg的情况下还是很原因使用的,除非场景或需求上由于一些条条框框的限制上不允许,比如这里的 wav 格式编码,可能直接通过代码实现只需要百来行代码,但引入 ffmpeg 这么一个庞然大物是否值得呢。

基于 HarmonyOS NEXT 广泛应用的 ArkTS 语言,众多前端技术得以在鸿蒙系统上顺畅运用。例如,在上述音频编码实现中,DataView 类和 fs 模块的表现与前端中的 DataView 以及 Node 环境下的 fs 模块的使用上高度相似,这使得在功能实现过程中减少了一些技术障碍。

就目前来看,HarmonyOS NEXT 在一定情况下为前端开发者拓展了新的领域方向,提供了更多选择的可能性。

附录

WAVE介绍