iOS AVDemo(2):音频编码,采集 PCM 数据编码为 AAC丨音视频工程示例

vx 搜索『gjzkeyframe』 关注『关键帧Keyframe』来及时获得最新的音视频技术文章。

毕加索《法落利炊烟》像素版

这个公众号会路线图 式的遍历分享音视频技术音视频基础(完成)音视频工具(完成)音视频工程示例(进行中) → 音视频工业实战(准备)。

iOS/Android 客户端开发同学如果想要开始学习音视频开发,最丝滑的方式是对音视频基础概念知识有一定了解后,再借助 iOS 平台的音视频能力上手去实践音视频的采集 → 编码 → 封装 → 解封装 → 解码 → 渲染过程,并借助音视频工具来分析和理解对应的音视频数据。

音视频工程示例这个栏目,我们将通过拆解采集 → 编码 → 封装 → 解封装 → 解码 → 渲染流程并实现 Demo 来向大家介绍如何在 iOS/Android 平台上手音视频开发。

这里是第二篇:iOS 音频编码 Demo。这个 Demo 里包含以下内容:

  • 1)实现一个音频采集模块;
  • 2)实现一个音频编码模块;
  • 3)串联音频采集和编码模块,将采集到的音频数据输入给 AAC 编码模块进行编码和存储;
  • 4)详尽的代码注释,帮你理解代码逻辑和原理。

你可以在关注微信公众号后,在公众号发送消息『AVDemo』来获取 Demo 的全部源码。

想要了解 AAC 编码,可以看看《音频编码:PCM 和 AAC 编码》

1、音频采集模块

在这个 Demo 中,音频采集模块 KFAudioCapture 的实现与 《iOS 音频采集 Demo》 中一样,这里就不再重复介绍了,其接口如下:

KFAudioCapture.h

#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>
#import "KFAudioConfig.h"

NS_ASSUME_NONNULL_BEGIN

@interface KFAudioCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFAudioConfig *)config;

@property (nonatomic, strong, readonly) KFAudioConfig *config;
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 音频采集数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 音频采集错误回调。

- (void)startRunning; // 开始采集音频数据。
- (void)stopRunning; // 停止采集音频数据。
@end

NS_ASSUME_NONNULL_END

2、音频编码模块

接下来,我们来实现一个音频编码模块 KFAudioEncoder,在这里输入采集后的数据,输出编码后的数据。

KFAudioEncoder.h

#import <Foundation/Foundation.h>
#import <CoreMedia/CoreMedia.h>

NS_ASSUME_NONNULL_BEGIN

@interface KFAudioEncoder : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithAudioBitrate:(NSInteger)audioBitrate;

@property (nonatomic, assign, readonly) NSInteger audioBitrate; // 音频编码码率。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 音频编码数据回调。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 音频编码错误回调。

- (void)encodeSampleBuffer:(CMSampleBufferRef)buffer; // 编码。
@end

NS_ASSUME_NONNULL_END

上面是 KFAudioEncoder 接口的设计,除了初始化方法,主要是有获取音频编码码率以及音频编码数据回调错误回调的接口,另外就是编码的接口。

其中编码接口对应着音频编码模块输入,数据回调接口则对应着输出。可以看到这里输入输出的参数都是 CMSampleBufferRef[1] 这个数据结构。它是对 CMSampleBuffer 的一个引用。

CMSampleBuffer 是 iOS 系统用来在音视频处理的 pipeline 中使用和传递媒体采样数据的核心数据结构。你可以认为它是 iOS 音视频处理 pipeline 中的流通货币,摄像头采集的视频数据接口、麦克风采集的音频数据接口、编码和解码数据接口、读取和存储视频接口、视频渲染接口等等,都以它作为参数。我们在 《iOS 音频采集 Demo》 一文中介绍音频采集接口的时候详细介绍过 CMSampleBuffer,可以去看看回顾一下。

所以,在这里我们也以 CMSampleBufferRef 作为编码模块输入和输出的接口参数。

KFAudioEncoder.m


#import "KFAudioEncoder.h"
#import <AudioToolbox/AudioToolbox.h>

@interface KFAudioEncoder () {
    char *_leftBuffer; // 待编码缓冲区。
    NSInteger _leftLength; // 待编码缓冲区的长度,动态。
    char *_aacBuffer; // 编码缓冲区。
    NSInteger _bufferLength; // 每次送给编码器的数据长度。
}
@property (nonatomic, assign) AudioConverterRef audioEncoderInstance; // 音频编码器实例。
@property (nonatomic, assign) CMFormatDescriptionRef aacFormat; // 音频编码参数。
@property (nonatomic, assign, readwrite) NSInteger audioBitrate; // 音频编码码率。
@property (nonatomic, assign) BOOL isError;
@property (nonatomic, strong) dispatch_queue_t encoderQueue;
@end

@implementation KFAudioEncoder

#pragma mark - Lifecycle
- (instancetype)initWithAudioBitrate:(NSInteger)audioBitrate {
    self = [super init];
    if (self) {
        _audioBitrate = audioBitrate;
        _encoderQueue = dispatch_queue_create("com.KeyFrameKit.audioEncoder", DISPATCH_QUEUE_SERIAL);
    }

    return self;
}

- (void)dealloc {
    // 清理编码器。
    if (_audioEncoderInstance) {
        AudioConverterDispose(_audioEncoderInstance);
        _audioEncoderInstance = nil;
    }
    if (_aacFormat) {
        CFRelease(_aacFormat);
        _aacFormat = NULL;
    }
    
    // 清理缓冲区。
    if (_aacBuffer) {
        free(_aacBuffer);
        _aacBuffer = NULL;
    }
    if (_leftBuffer) {
        free(_leftBuffer);
        _leftBuffer = NULL;
    }
}

#pragma mark - Utility
- (void)setupAudioEncoderInstanceWithInputAudioFormat:(AudioStreamBasicDescription)inputFormat error:(NSError **)error {
    // 1、设置音频编码器输出参数。其中一些参数与输入的音频数据参数一致。
    AudioStreamBasicDescription outputFormat = {0};
    outputFormat.mSampleRate = inputFormat.mSampleRate; // 输出采样率与输入一致。
    outputFormat.mFormatID = kAudioFormatMPEG4AAC; // AAC 编码格式。常用的 AAC 编码格式:kAudioFormatMPEG4AAC、kAudioFormatMPEG4AAC_HE_V2。
    outputFormat.mChannelsPerFrame = (UInt32) inputFormat.mChannelsPerFrame; // 输出声道数与输入一致。
    outputFormat.mFramesPerPacket = 1024; // 每个包的帧数。AAC 固定是 1024,这个是由 AAC 编码规范规定的。对于未压缩数据设置为 1。
    outputFormat.mBytesPerPacket = 0; // 每个包的大小。动态大小设置为 0。
    outputFormat.mBytesPerFrame = 0; // 每帧的大小。压缩格式设置为 0。
    outputFormat.mBitsPerChannel = 0; // 压缩格式设置为 0。
    
    // 2、基于音频输入和输出参数创建音频编码器。
    OSStatus result = AudioConverterNew(&inputFormat, &outputFormat, &_audioEncoderInstance);
    if (result != noErr) {
        *error = [NSError errorWithDomain:NSStringFromClass(self.class) code:result userInfo:nil];
        return;
    }
    
    // 3、设置编码器参数:音频编码码率。
    UInt32 outputBitrate = (UInt32) self.audioBitrate;
    result = AudioConverterSetProperty(_audioEncoderInstance, kAudioConverterEncodeBitRate, sizeof(outputBitrate), &outputBitrate);
    if (result != noErr) {
        *error = [NSError errorWithDomain:NSStringFromClass(self.class) code:result userInfo:nil];
        return;
    }
    
    // 4、创建编码格式信息。
    result = CMAudioFormatDescriptionCreate(kCFAllocatorDefault, &outputFormat, 0, NULL, 0, NULL, nil, &_aacFormat);
    if (result != noErr) {
        *error = [NSError errorWithDomain:NSStringFromClass(self.class) code:result userInfo:nil];
        return;
    }
    
    // 5、设置每次送给编码器的数据长度。
    // 这里设置每次送给编码器的数据长度为:1024 * 2(16 bit 采样深度) * 声道数量,这个长度为什么要这么计算呢?
    // 因为我们每次调用 AudioConverterFillComplexBuffer 编码时,是送进去一个包(packet),而对于 AAC 来讲,mFramesPerPacket 需要是 1024,即 1 个 packet 有 1024 帧,而每个音频帧的大小是:2(16 bit 采样深度) * 声道数量。
    _bufferLength = 1024 * 2 * inputFormat.mChannelsPerFrame;
    
    // 6、初始化待编码缓冲区和编码缓冲区。
    if (!_leftBuffer) {
        // 待编码缓冲区长度达到 _bufferLength,就会送一波给编码器,所以大小 _bufferLength 够用了。
        _leftBuffer = malloc(_bufferLength);
    }
    if (!_aacBuffer) {
        // AAC 编码缓冲区只要装得下 _bufferLength 长度的 PCM 数据编码后的数据就好了,编码是压缩,所以大小 _bufferLength 也够用了。
        _aacBuffer = malloc(_bufferLength);
    }
}

- (void)encodeSampleBuffer:(CMSampleBufferRef)buffer {
    if (!buffer || !CMSampleBufferGetDataBuffer(buffer) || self.isError) {
        return;
    }
    
    // 异步处理,防止主线程卡顿。
    __weak typeof(self) weakSelf = self;
    CFRetain(buffer);
    dispatch_async(_encoderQueue, ^{
        [weakSelf encodeSampleBufferInternal:buffer];
        CFRelease(buffer);
    });
}

- (void)encodeSampleBufferInternal:(CMSampleBufferRef)buffer {
    // 1、从输入数据中获取音频格式信息。
    CMAudioFormatDescriptionRef audioFormatRef = CMSampleBufferGetFormatDescription(buffer);
    if (!audioFormatRef) {
        return;
    }
    // 获取音频参数信息,AudioStreamBasicDescription 包含了音频的数据格式、声道数、采样位深、采样率等参数。
    AudioStreamBasicDescription audioFormat = *CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatRef);

    // 2、根据音频参数创建编码器实例。
    NSError *error = nil;
    // 第一次编码时创建编码器。
    if (!_audioEncoderInstance) {
        [self setupAudioEncoderInstanceWithInputAudioFormat:audioFormat error:&error];
        if (error) {
            [self callBackError:error];
            return;
        }
        if (!_audioEncoderInstance) {
            return;
        }
    }
    
    // 3、获取输入数据中的 PCM 数据。
    CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(buffer);
    size_t audioLength;
    char *dataPointer = NULL;
    CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &audioLength, &dataPointer);
    if (audioLength == 0 || !dataPointer) {
        return;
    }
    
    // 4、处理音频时间戳信息。
    CMSampleTimingInfo timingInfo = {CMTimeMake(CMSampleBufferGetNumSamples(buffer), audioFormat.mSampleRate), CMSampleBufferGetPresentationTimeStamp(buffer), kCMTimeInvalid};
    
    // 5、基于编码缓冲区对 PCM 数据进行编码。
    if (_leftLength + audioLength >= _bufferLength) {
        // 当待编码缓冲区遗留数据加上新来的数据长度(_leftLength + audioLength)大于每次给编码器的数据长度(_bufferLength)时,则进行循环编码,每次送给编码器长度为 _bufferLength 的数据量。
        
        // 拷贝待编码的数据到缓冲区 totalBuffer。
        NSInteger totalSize = _leftLength + audioLength; // 当前总数据长度。
        NSInteger encodeCount = totalSize / _bufferLength; // 计算给编码器送数据的次数。
        char *totalBuffer = malloc(totalSize);
        char *p = totalBuffer;
        memset(totalBuffer, 0, (int) totalSize);
        memcpy(totalBuffer, _leftBuffer, _leftLength); // 拷贝上次遗留的数据。
        memcpy(totalBuffer + _leftLength, dataPointer, audioLength); // 拷贝这次新来的数据。
        
        // 分 encodeCount 次给编码器送数据。
        for (NSInteger index = 0; index < encodeCount; index++) {
            [self encodeBuffer:p timing:timingInfo]; // 调用编码方法。
            p += _bufferLength;
        }
        
        // 处理不够 _bufferLength 长度的剩余数据,先存在 _leftBuffer 中,等下次凑足一次编码需要的数据再编码。
        _leftLength = totalSize % _bufferLength;
        memset(_leftBuffer, 0, _bufferLength);
        memcpy(_leftBuffer, totalBuffer + (totalSize - _leftLength), _leftLength);
        
        // 清理。
        free(totalBuffer);
    } else {
        // 否则,就先存到待编码缓冲区,等下一次数据够了再送给编码器。
        memcpy(_leftBuffer + _leftLength, dataPointer, audioLength);
        _leftLength = _leftLength + audioLength;
    }
}

- (void)encodeBuffer:(char *)buffer timing:(CMSampleTimingInfo)timing {
    // 1、创建编码器接口对应的待编码缓冲区 AudioBufferList,填充待编码的数据。
    AudioBuffer inBuffer;
    AudioStreamBasicDescription audioFormat = *CMAudioFormatDescriptionGetStreamBasicDescription(_aacFormat);
    inBuffer.mNumberChannels = (UInt32) audioFormat.mChannelsPerFrame;
    inBuffer.mData = buffer; // 填充待编码数据。
    inBuffer.mDataByteSize = (UInt32) _bufferLength; // 设置待编码数据长度。
    AudioBufferList inBufferList;
    inBufferList.mNumberBuffers = 1;
    inBufferList.mBuffers[0] = inBuffer;
    
    //  2、创建编码输出缓冲区 AudioBufferList 接收编码后的数据。
    AudioBufferList outBufferList;
    outBufferList.mNumberBuffers = 1;
    outBufferList.mBuffers[0].mNumberChannels = inBuffer.mNumberChannels;
    outBufferList.mBuffers[0].mDataByteSize = inBuffer.mDataByteSize; // 设置编码缓冲区大小。
    outBufferList.mBuffers[0].mData = _aacBuffer; // 绑定缓冲区空间。
    
    // 3、编码。
    UInt32 outputDataPacketSize = 1; // 每次编码 1 个包。1 个包有 1024 个帧,这个对应创建编码器实例时设置的 mFramesPerPacket。
    // 需要在回调方法 inputDataProcess 中将待编码的数据拷贝到编码器的缓冲区的对应位置。这里把我们自己创建的待编码缓冲区 AudioBufferList 作为 inInputDataProcUserData 传入,在回调方法中直接拷贝它。
    OSStatus status = AudioConverterFillComplexBuffer(_audioEncoderInstance, inputDataProcess, &inBufferList, &outputDataPacketSize, &outBufferList, NULL);
    if (status != noErr) {
        [self callBackError:[NSError errorWithDomain:NSStringFromClass(self.class) code:status userInfo:nil]];
        return;
    }
    
    // 4、获取编码后的 AAC 数据并进行封装。
    size_t aacEncoderSize = outBufferList.mBuffers[0].mDataByteSize;
    char *blockBufferDataPoter = malloc(aacEncoderSize);
    memcpy(blockBufferDataPoter, _aacBuffer, aacEncoderSize);
    // 编码数据封装到 CMBlockBuffer 中。
    CMBlockBufferRef blockBuffer = NULL;
    status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault,
                                                 blockBufferDataPoter,
                                                 aacEncoderSize,
                                                 NULL,
                                                 NULL,
                                                 0,
                                                 aacEncoderSize,
                                                 0,
                                                 &blockBuffer);
    if (status != kCMBlockBufferNoErr) {
        return;
    }
    // 编码数据 CMBlockBuffer 再封装到 CMSampleBuffer 中。
    CMSampleBufferRef sampleBuffer = NULL;
    const size_t sampleSizeArray[] = {aacEncoderSize};
    status = CMSampleBufferCreateReady(kCFAllocatorDefault,
                                       blockBuffer,
                                       _aacFormat,
                                       1,
                                       1,
                                       &timing,
                                       1,
                                       sampleSizeArray,
                                       &sampleBuffer);
    CFRelease(blockBuffer);
    
    // 5、回调编码数据。
    if (self.sampleBufferOutputCallBack) {
        self.sampleBufferOutputCallBack(sampleBuffer);
    }
    if (sampleBuffer) {
        CFRelease(sampleBuffer);
    }
}

- (void)callBackError:(NSError*)error {
    self.isError = YES;
    if(error && self.errorCallBack){
        dispatch_async(dispatch_get_main_queue(), ^{
            self.errorCallBack(error);
        });
    }
}

#pragma mark - Encoder CallBack
static OSStatus inputDataProcess(AudioConverterRef inConverter,
                                 UInt32 *ioNumberDataPackets,
                                 AudioBufferList *ioData,
                                 AudioStreamPacketDescription **outDataPacketDescription,
                                 void *inUserData) {
    // 将待编码的数据拷贝到编码器的缓冲区的对应位置进行编码。
    AudioBufferList bufferList = *(AudioBufferList *) inUserData;
    ioData->mBuffers[0].mNumberChannels = 1;
    ioData->mBuffers[0].mData = bufferList.mBuffers[0].mData;
    ioData->mBuffers[0].mDataByteSize = bufferList.mBuffers[0].mDataByteSize;
    
    return noErr;
}

@end

上面是 KFAudioEncoder 的实现,从代码上可以看到主要有这几个部分:

  • 1)创建音频编码实例。第一次调用 -encodeSampleBuffer:-encodeSampleBufferInternal: 才会创建音频编码实例。
    • -setupAudioEncoderInstanceWithInputAudioFormat: 方法中实现。
  • 2)实现音频编码逻辑,并在将数据封装到 CMSampleBufferRef 结构中,抛给 KFAudioEncoder 的对外数据回调接口。
    • -encodeSampleBuffer:-encodeSampleBufferInternal:-encodeBuffer:timing: 中实现编码流程,其中涉及到待编码缓冲区、编码缓冲区的管理,并最终在 inputDataProcess(...) 回调中将待编码的数据拷贝到编码器的缓冲区进行编码。
  • 3)捕捉音频编码过程中的错误,抛给 KFAudioEncoder 的对外错误回调接口。
    • -encodeSampleBufferInternal:-encodeBuffer:timing: 等方法中捕捉错误,在 -callBackError: 方法向外回调。
  • 4)清理音频编码器实例、待编码缓冲区、编码缓冲区。
    • -dealloc 方法中实现。

更具体细节见上述代码及其注释。

3、采集音频数据进行 AAC 编码和存储

我们在一个 ViewController 中来实现音频采集及编码逻辑,并将编码后的数据加上 ADTS[2] 头信息存储为 AAC 数据。

关于 ADTS,在《音频编码:PCM 和 AAC 编码》中也有介绍,可以去看看了解一下。


#import "KFAudioEncoderViewController.h"
#import <AVFoundation/AVFoundation.h>
#import "KFAudioCapture.h"
#import "KFAudioEncoder.h"
#import "KFAudioTools.h"

@interface KFAudioEncoderViewController ()
@property (nonatomic, strong) KFAudioConfig *audioConfig;
@property (nonatomic, strong) KFAudioCapture *audioCapture;
@property (nonatomic, strong) KFAudioEncoder *audioEncoder;
@property (nonatomic, strong) NSFileHandle *fileHandle;
@end

@implementation KFAudioEncoderViewController
#pragma mark - Property
- (KFAudioConfig *)audioConfig {
    if (!_audioConfig) {
        _audioConfig = [KFAudioConfig defaultConfig];
    }
    
    return _audioConfig;
}

- (KFAudioCapture *)audioCapture {
    if (!_audioCapture) {
        __weak typeof(self) weakSelf = self;
        _audioCapture = [[KFAudioCapture alloc] initWithConfig:self.audioConfig];
        _audioCapture.errorCallBack = ^(NSError* error) {
            NSLog(@"KFAudioCapture error:%zi %@", error.code, error.localizedDescription);
        };
        // 音频采集数据回调。在这里采集的 PCM 数据送给编码器。
        _audioCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
            [weakSelf.audioEncoder encodeSampleBuffer:sampleBuffer];
        };
    }
    
    return _audioCapture;
}

- (KFAudioEncoder *)audioEncoder {
    if (!_audioEncoder) {
        __weak typeof(self) weakSelf = self;
        _audioEncoder = [[KFAudioEncoder alloc] initWithAudioBitrate:96000];
        _audioEncoder.errorCallBack = ^(NSError* error) {
            NSLog(@"KFAudioEncoder error:%zi %@", error.code, error.localizedDescription);
        };
        // 音频编码数据回调。在这里将 AAC 数据写入文件。
        _audioEncoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
            if (sampleBuffer) {
                // 1、获取音频编码参数信息。
                AudioStreamBasicDescription audioFormat = *CMAudioFormatDescriptionGetStreamBasicDescription(CMSampleBufferGetFormatDescription(sampleBuffer));
                
                // 2、获取音频编码数据。AAC 裸数据。
                CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
                size_t totolLength;
                char *dataPointer = NULL;
                CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &totolLength, &dataPointer);
                if (totolLength == 0 || !dataPointer) {
                    return;
                }
                
                // 3、在每个 AAC packet 前先写入 ADTS 头数据。
                // 由于 AAC 数据存储文件时需要在每个包(packet)前添加 ADTS 头来用于解码器解码音频流,所以这里添加一下 ADTS 头。
                [weakSelf.fileHandle writeData:[KFAudioTools adtsDataWithChannels:audioFormat.mChannelsPerFrame sampleRate:audioFormat.mSampleRate rawDataLength:totolLength]];
                
                // 4、写入 AAC packet 数据。
                [weakSelf.fileHandle writeData:[NSData dataWithBytes:dataPointer length:totolLength]];
            }
        };
    }
    
    return _audioEncoder;
}

- (NSFileHandle *)fileHandle {
    if (!_fileHandle) {
        NSString *audioPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:@"test.aac"];
        NSLog(@"AAC file path: %@", audioPath);
        [[NSFileManager defaultManager] removeItemAtPath:audioPath error:nil];
        [[NSFileManager defaultManager] createFileAtPath:audioPath contents:nil attributes:nil];
        _fileHandle = [NSFileHandle fileHandleForWritingAtPath:audioPath];
    }

    return _fileHandle;
}

#pragma mark - Lifecycle
- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self setupAudioSession];
    [self setupUI];
    
    // 完成音频编码后,可以将 App Document 文件夹下面的 test.aac 文件拷贝到电脑上,使用 ffplay 播放:
    // ffplay -I test.aac
}

- (void)dealloc {
    if (_fileHandle) {
        [_fileHandle closeFile];
    }
}

#pragma mark - Setup
- (void)setupUI {
    self.edgesForExtendedLayout = UIRectEdgeAll;
    self.extendedLayoutIncludesOpaqueBars = YES;
    self.title = @"Audio Encoder";
    self.view.backgroundColor = [UIColor whiteColor];
    
    // Navigation item.
    UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
    UIBarButtonItem *stopBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Stop" style:UIBarButtonItemStylePlain target:self action:@selector(stop)];
    self.navigationItem.rightBarButtonItems = @[startBarButton, stopBarButton];
}

- (void)setupAudioSession {
    NSError *error = nil;
    
    // 1、获取音频会话实例。
    AVAudioSession *session = [AVAudioSession sharedInstance];

    // 2、设置分类和选项。
    [session setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:AVAudioSessionCategoryOptionMixWithOthers | AVAudioSessionCategoryOptionDefaultToSpeaker error:&error];
    if (error) {
        NSLog(@"AVAudioSession setCategory error.");
        error = nil;
        return;
    }
    
    // 3、设置模式。
    [session setMode:AVAudioSessionModeVideoRecording error:&error];
    if (error) {
        NSLog(@"AVAudioSession setMode error.");
        error = nil;
        return;
    }

    // 4、激活会话。
    [session setActive:YES error:&error];
    if (error) {
        NSLog(@"AVAudioSession setActive error.");
        error = nil;
        return;
    }
}

#pragma mark - Action
- (void)start {
    [self.audioCapture startRunning];
}

- (void)stop {
    [self.audioCapture stopRunning];
}

@end

上面是 KFAudioEncoderViewController 的实现,其中主要包含这几个部分:

  • 1)在采集音频前需要设置 AVAudioSession[3] 为正确的采集模式。

    • -setupAudioSession 中实现。
  • 2)通过启动和停止音频采集来驱动整个采集和编码流程。

    • 分别在 -start-stop 中实现开始和停止动作。
  • 3)在采集模块 KFAudioCapture 的数据回调中将数据交给编码模块 KFAudioEncoder 进行编码。

    • KFAudioCapturesampleBufferOutputCallBack 回调中实现。
  • 4)在编码模块 KFAudioEncoder 的数据回调中获取编码后的 AAC 裸流数据,并在每个 AAC packet 前写入 ADTS 头数据,存储到文件中。

    • KFAudioEncodersampleBufferOutputCallBack 回调中实现。
    • 其中生成一个 AAC packet 对应的 ADTS 头数据在 KFAudioTools 类的工具方法 +adtsDataWithChannels:sampleRate:rawDataLength: 中实现。

    KFAudioTools.m

#import "KFAudioTools.h"

@implementation KFAudioTools

// 按音频参数生产 AAC packet 对应的 ADTS 头数据。
// 当编码器编码的是 AAC 裸流数据时,需要在每个 AAC packet 前添加一个 ADTS 头用于解码器解码音频流。
// 参考文档:
// ADTS 格式参考:http://wiki.multimedia.cx/index.php?title=ADTS
// MPEG-4 Audio 格式参考:http://wiki.multimedia.cx/index.php?title=MPEG-4_Audio#Channel_Configurations
+ (NSData *)adtsDataWithChannels:(NSInteger)channels sampleRate:(NSInteger)sampleRate rawDataLength:(NSInteger)rawDataLength {
    // 1、创建数据缓冲区。
    int adtsLength = 7; // ADTS 头固定 7 字节。
    char *packet = malloc(sizeof(char) * adtsLength);
    
    // 2、设置各数据字段。
    int profile = 2; // 2 表示 AAC LC。
    NSInteger sampleRateIndex = [self.class sampleRateIndex:sampleRate]; // 取得采样率对应的 index。
    int channelCfg = (int) channels; // MPEG-4 Audio Channel Configuration。
    NSUInteger fullLength = adtsLength + rawDataLength; // 这里的长度字段是:ADTS 头数据和 AAC packet 数据的总长度。
    
    //  3、填充 ADTS 数据。
    packet[0] = (char) 0xFF; // 11111111     = syncword
    packet[1] = (char) 0xF9; // 1111 1 00 1  = syncword MPEG-2 Layer CRC
    packet[2] = (char) (((profile - 1) << 6) + (sampleRateIndex << 2) + (channelCfg >> 2));
    packet[3] = (char) (((channelCfg & 3) << 6) + (fullLength >> 11));
    packet[4] = (char) ((fullLength & 0x7FF) >> 3);
    packet[5] = (char) (((fullLength & 7) << 5) + 0x1F);
    packet[6] = (char) 0xFC;
    NSData *data = [NSData dataWithBytesNoCopy:packet length:adtsLength freeWhenDone:YES];
    
    return data;
}

// 音频采样率对应的 index。
+ (NSInteger)sampleRateIndex:(NSInteger)frequencyInHz {
    NSInteger sampleRateIndex = 0;
    switch (frequencyInHz) {
        case 96000:
            sampleRateIndex = 0;
            break;
        case 88200:
            sampleRateIndex = 1;
            break;
        case 64000:
            sampleRateIndex = 2;
            break;
        case 48000:
            sampleRateIndex = 3;
            break;
        case 44100:
            sampleRateIndex = 4;
            break;
        case 32000:
            sampleRateIndex = 5;
            break;
        case 24000:
            sampleRateIndex = 6;
            break;
        case 22050:
            sampleRateIndex = 7;
            break;
        case 16000:
            sampleRateIndex = 8;
            break;
        case 12000:
            sampleRateIndex = 9;
            break;
        case 11025:
            sampleRateIndex = 10;
            break;
        case 8000:
            sampleRateIndex = 11;
            break;
        case 7350:
            sampleRateIndex = 12;
            break;
        default:
            sampleRateIndex = 15;
    }
    
    return sampleRateIndex;
}

@end

4、用工具播放 AAC 文件

完成音频采集和编码后,可以将 App Document 文件夹下面的 test.aac 文件拷贝到电脑上,使用 ffplay 播放来验证一下音频采集是效果是否符合预期:

$ ffplay -I test.aac

这里在播放 AAC 文件时不必像播放 PCM 文件那样设置音频参数,这正是因为我们已经将对应的参数信息编码到 ADTS 头部数据中去了,播放解码时可以从中解析出这些信息从而正确的解码 AAC。

关于播放 AAC 文件的工具,可以参考《FFmpeg 工具》第 2 节 ffplay 命令行工具《可视化音视频分析工具》第 1.1 节 Adobe Audition

参考资料

[1]CMSampleBufferRef: https://developer.apple.com/documentation/coremedia/cmsamplebufferref/

[2]ADTS 格式: http://wiki.multimedia.cx/index.php?title=ADTS

[3]AVAudioSession: https://developer.apple.com/documentation/avfaudio/avaudiosession/

推荐阅读

《iOS AVDemo(1):音频采集》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容