iOS端音频边录边转和声波图的实现

5,891 阅读9分钟

我司和我同项目的Android小伙伴分享了技术文章, 咱大iOS也不可以落后, 整理了一下关于音频处理的一些内容, 希望对大家有所帮助.

Talk is cheap, show you the code WaveDemo

##转码前奏 我所在项目, 需要将音频上传至服务器, iOS原生的录音产生的PCM文件过大, 为了统一三端, 我们决定使用mp3格式. iOS录音的输出参数默认不支持mp3(那个字段没用...), 所以我们需要使用lame进行转码. 网上可以找到的lame.a多是iOS6, 7, 并且有的不支持bitcode, 容我做个悲伤的表情🤣 我在github上找到了一个编译lame源码的库build-lame-for-iOS, 支持bitcode, 并且可修改最低支持版本, 自行修改sh文件

tip: lipo操作可操作libXXX.a文件, 增删平台依赖.

##转码进行时 搞完lame的问题, 我们开始进行编码, 最开始我使用Google大法, 找到了使用lame的方式, 核心代码如下:

@try {
        int read, write;
        FILE *pcm = fopen([cafFilePath cStringUsingEncoding:1], "rb");  //source 被转换的音频文件位置
        fseek(pcm, 4*1024, SEEK_CUR);                                   //skip file header
        FILE *mp3 = fopen([mp3FilePath cStringUsingEncoding:1], "wb");  //output 输出生成的Mp3文件位置
        const int PCM_SIZE = 8192;
        const int MP3_SIZE = 8192;
        short int pcm_buffer[PCM_SIZE*2];
        unsigned char mp3_buffer[MP3_SIZE];

        lame_t lame = lame_init();
        lame_set_in_samplerate(lame, 22050.0);
        lame_set_VBR(lame, vbr_default);
        lame_init_params(lame);

        do {
            read = fread(pcm_buffer, 2*sizeof(short int), PCM_SIZE, pcm);
            if (read == 0)
                write = lame_encode_flush(lame, mp3_buffer, MP3_SIZE);
            else
                write = lame_encode_buffer_interleaved(lame, pcm_buffer, read, mp3_buffer, MP3_SIZE);

            fwrite(mp3_buffer, write, 1, mp3);

        } while (read != 0);

        lame_close(lame);
        fclose(mp3);
        fclose(pcm);
    }
    @catch (NSException *exception) {
        NSLog(@"%@",[exception description]);
    }
    @finally {
        return mp3FilePath;
    }

我们完成了第一步, 录制完成后将PCM转为mp3 让我先检验一下音频可否播放等问题, 然后问题就来了 用mac自带的iTunes播放, 获取的总时长不正确.不用问, 肯定是转码出了问题, 查找了一些资料得知, lame_set_VBR的参数vbr_default是变码率vbr形式的, 默认的播放器AVPlayer是使用cbr均码率的形式识别播放, 导致时长不正确, 所以这里调整上面的一行代码:

lame_set_VBR(lame, vbr_off);

注意模拟器和真机的采样率有些许不同, 如遇到播放杂音的状况可调整为

lame_set_in_samplerate(lame, 44100);

##优化转码 其实就是边录边转了, 这里我查看了一些文章, 大多基于AVAudioRecorder实现的方式比较粗暴, 不想使用. 我还查到了基于AVAudioQueue的, 不过api多C语言. 想起来前一阵看的Apple的session中有关于AVAudioEngine的介绍, 使用起来更加oc, 本着折腾就是学习的心, 使用AVAudioEngine进行转码. 这里我就不多做介绍了,可以查看文章iOS AVAudioEngine 使用AVAudioEngine可以拿到时时的音频buffer, 对其进行转码即可, 将转码后的data进行append(可自己改造, 使用AFNetworking进行流上传). 核心代码如下:

  • 转码准备工作,创建engine,并初始化lame
  private func initLame() {
    
    engine = AVAudioEngine()
    guard let engine = engine,
          let input = engine.inputNode else {
        return
    }
    
    let format = input.inputFormat(forBus: 0)
    let sampleRate = Int32(format.sampleRate) / 2
    
    lame = lame_init()
    lame_set_in_samplerate(lame, sampleRate);
    lame_set_VBR_mean_bitrate_kbps(lame, 96);
    lame_set_VBR(lame, vbr_off);
    lame_init_params(lame);
  }

设置AVAudioSession,设置偏好的采样率和获取buffer的io频率

 let session = AVAudioSession.sharedInstance()
    do {
      try session.setCategory(AVAudioSessionCategoryPlayAndRecord)
      try session.setPreferredSampleRate(44100)
      try session.setPreferredIOBufferDuration(0.1)
      try session.setActive(true)
      initLame()
    } catch {
      print("seesion设置")
      return
    }

计算音量 使用 Accelerate 库进行高效计算, 详情查看 Level Metering with AVAudioEngine

let levelLowpassTrig: Float = 0.5
        var avgValue: Float32 = 0
        vDSP_meamgv(buf, 1, &avgValue, vDSP_Length(frameLength))
        this.averagePowerForChannel0 = (levelLowpassTrig * ((avgValue==0) ? -100 : 20.0 * log10f(avgValue))) + ((1-levelLowpassTrig) * this.averagePowerForChannel0)
        
        let volume = min((this.averagePowerForChannel0 + Float(55))/55.0, 1.0)
        
        this.minLevel = min(this.minLevel, volume)
        this.maxLevel = max(this.maxLevel, volume)
        // 切回去, 更新UI
        DispatchQueue.main.async {
          this.delegate?.record(this, voluem: volume)
        }

结束操作

public func stop() {
    engine?.inputNode?.removeTap(onBus: 0)
    engine = nil
    do {
      var url = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
      let name = String(CACurrentMediaTime()).appending(".mp3")
      url.appendPathComponent(name)
      if !data.isEmpty {
        try data.write(to: url)
      }
      else {
        print("空文件")
      }
    } catch {
      print("文件操作")
    }
    data.removeAll()
  }

几个需要注意的点

  1. 参考Lame使用VBR模式编码MP3文件,播放总时间长度不对的解决方法LAME 是一个开源的MP3解码编码工具, 我没有回溯文件, 所以清空了vbr的文件头, 其实使用的是cbr的形式
lame_set_bWriteVbrTag(_lame, 0);
  1. 在input的回调中, 我们修改了bufferframeLength, 因为默认的input的回调频率是0.375s, 我们可以通过修改frameLength来达到修改频率的目的. 使用这样的方法不够优雅,所以通过设置session的ioduration来达到类似的目的。

  2. 录制过程中没有办法取得当前录制时长, 请自行使用NSDate, NSTimer等方式进行计算.

  3. 在模拟器上一切ok, 在我的小6上, 录制的音频播放出来是快进的🤣,debug了好久发现, 在真机上, 如果连续播放录制, 有时AVAudioEngine的input上的format拿到的采样率sampleRate不是预期的44100而是16000. 解决办法有两种

  • 设置AudioSession的preferdSampleRate
AVAudioSession *sessionInstance = [AVAudioSession sharedInstance];
[sessionInstance setPreferredSampleRate:kPreferredSampleRate error:&error]
  • 或者使用mixnode的方式对采样率进行处理, 可参考这个帖子

##同步Android波形图 因为重构以上部分的内容, 导致业务上拉后Android小伙伴, 他已经完成了波形图的绘制, 可以查看效果图.

wave.gif

Android自绘动画实现与一些优化思考——以智课批改App录音波形动画为例 Android小伙伴详细介绍了如何绘制该图形, 在他的帮助下, 我在iOS端也实现了该效果.

基本流程

  1. 计算曲线点的位置和对称衰减函数
  2. 根据计算绘制点, 使用CAShapeLayer和UIBezierPath
  3. 根据时间, 改边Φ值, 实现曲线位移效果
  4. 根据音量volume和衰减函数, 改变振幅, 实现上下波动.

优化手段大体相同

  • 降低绘制密度
  • 减少重复实时计算量
  • 复用, 减少对象创建销毁

核心代码如下:

CGFloat reduction[kPointNumber];
CGFloat perVolume;
NSInteger count;
@property (nonatomic, assign) CGFloat targetVolue;
@property (nonatomic, copy) NSArray<NSNumber *> *amplitudes;
@property (nonatomic, copy) NSArray<CAShapeLayer *> *shapeLayers;
@property (nonatomic, copy) NSArray<UIBezierPath *> *paths;
- (void)doSomeInit {
  perVolume = 0.15;
  count = 0;
  self.amplitudes = @[@0.6, @0.35, @0.1, @-0.1];
  self.shapeLayers = [self.amplitudes bk_map:^id(id obj) {
      CAShapeLayer *layer = [self creatLayer];
      [self.layer addSublayer:layer];
      return layer;
    }];
    self.shapeLayers.firstObject.lineWidth = 2;
    self.paths = [self.amplitudes bk_map:^id(id obj) {
      return [UIBezierPath bezierPath];
    }];
  for (int i = 0; i < kPointNumber; i++) {
      reduction[i] = self.height / 2.0 * 4 / (4 + pow((i/(CGFloat)kPointNumber - 0.5) * 3, 4));
    }
}
 - (CAShapeLayer *)creatLayer {
  CAShapeLayer *layer = [CAShapeLayer layer];
  layer.fillColor = [UIColor clearColor].CGColor;
  layer.strokeColor = [UIColor defaultColor].CGColor;
  layer.lineWidth = 0.2;
  return layer;
}
// 用来忽略变化较小的波动
- (void)setTargetVolue:(CGFloat)targetVolue {
  if (ABS(_targetVolue - targetVolue) > perVolume) {
    _targetVolue = targetVolue;
  }
}
// 在每个CADisplayLink周期中, 平滑调整音量.
- (void)softerChangeVolume {
  CGFloat target = self.targetVolue;
  if (volume < target - perVolume) {
    volume += perVolume;
  } else if (volume > target + perVolume) {
    if (volume < perVolume * 2) {
      volume = perVolume * 2;
    } else {
      volume -= perVolume;
    }
  } else {
    volume = target;
  }
}

- (void)updatePaths:(CADisplayLink *)sender {
  // 坐标轴取[-3,3], 屏幕取像素点64份
  NSInteger xLen = 64;
  count++;
  [self softerChangeVolume];
  for (int i = 0; i < xLen; i++) {
    CGFloat left = i/(CGFloat)xLen * self.width;
    CGFloat x = (i/(CGFloat)xLen - 0.5) * 3;
    tmpY = volume * reduction[i] * sin(M_PI*x - count*0.2);
    for (int j = 0; j < self.amplitudes.count ; j++) {
      CGPoint point = CGPointMake(left, tmpY * [self.amplitudes[j] doubleValue]  + self.height/2);
      UIBezierPath *path = self.paths[j];
      if (i == 0) {
        [path moveToPoint:point];
      } else {
        [path addLineToPoint:point];
      }
    }
  }
  for (int i = 0; i < self.paths.count; i++) {
    self.shapeLayers[i].path = self.paths[i].CGPath;
  }
  [self.paths bk_each:^(UIBezierPath *obj) {
    [obj removeAllPoints];
  }];
}

当然也有一些小小的不足, 在改变path的时候, 我发现cpu占用率打到了15%, 不知道有没有办法继续优化, 大家集思广益😜

update: 波形图采用了另一种实现方式,上面尽是对Android同学的致敬🙆🏻‍♂️

Demo完成, 用Swift写完发现和oc的效果不一样, 做了一些调整... 链接如下: WaveDemo

如果帮助到了你, 可以点一波关注, 走一波鱼丸 不对不对, 给文章点个喜欢, 作者点个关注就行了😘

iOS - 录音文件lame转换MP3相关配置 build-lame-for-iOS iOS中使用lame将PCM文件转换成MP3(边录边转) IOS 实现录音PCM转MP3格式(边录音边转码) iOS AVAudioEngine Android自绘动画实现与一些优化思考——以智课批改App录音波形动画为例