macOS coreAudio 之 AudioQueue 播放本地音频文件

  • Post author:
  • Post category:其他




macOS

的音频模块使用还是和

iOS

有细微差别的。

今天记录是的是 使用

AudioQueue

配合

AudioFile

进行播放macOS 本地音频文件

本文打仓库代码为:

JBPlayLocalMusicFile.m


CoreAudio

作为

Apple

音频系统中音频库的集合,今天需要使用到的库为:


  • AudioQueue

    位于

    <AudioToolbox/AudioQueue.h>

    , 作为输出模块,输入音频到系统默认扬声器

  • AudioFile

    位于

    <AudioToolbox/AudioFile.h>

    , 读取本地音频文件,然后将读取的

    Buffer

    塞入

    AudioQueue

    的音频队列中,等待系统扬声器宠幸。

读取和播放的大致流程为:
在这里插入图片描述



1.

前置说明

全局设置两个值分别为

//每个缓冲区0.5秒的数据量
#define kBufferDurationInSeconds 0.5
//分配三个缓冲区
#define kNumberBuffer 3

  • kBufferDurationInSeconds

    代表

    AudioQueue

    每个缓冲区存储 0.5秒时间的数据,最后我们代码里面会通过计算转换成0.5秒时间的数据量的 内存控件

  • kNumberBuffer

    代表

    AudioQueue

    中缓冲区的数量,我们知道至少需要两个缓冲区,其中一个采集数据的缓冲区

    A

    , 另外一个是 回调函数的缓冲区

    B

    。 当采集的数据达到0.5秒的内存大小后,

    A

    缓冲区会将所有数据转移到

    B

    缓冲区,然后A继续采集,

    B

    在回调函数中供用户使用。但是考虑数据的连贯性,如果哪个环节出问题,会导致数据A缓冲区不能及时进行数据采集,所以需要一个备用的缓冲区

    C

    来进行应急。



2.

辅助宏

播放demo只负责调通逻辑和了解学习API,并没有处理错误情况,我们将所有的错误只做了log输出,

#include <TargetConditionals.h>

//负责将 OSStatus 转成 fourcc
#if TARGET_RT_BIG_ENDIAN
#   define FourCC2Str(fourcc) (const char[]){*((char*)&fourcc), *(((char*)&fourcc)+1), *(((char*)&fourcc)+2), *(((char*)&fourcc)+3),0}
#else
#   define FourCC2Str(fourcc) (const char[]){*(((char*)&fourcc)+3), *(((char*)&fourcc)+2), *(((char*)&fourcc)+1), *(((char*)&fourcc)+0),0}
#endif

#define printErr(logStr, status) \
    if (status != noErr) {\
        NSLog(@"==== 出现错误: %@ code: %d(%s)", logStr, (int)status, FourCC2Str(status));\
    }

日志输入大概这酱紫


CoreAudioDemo[37280:4192311] ==== 出现错误: AudioFileGetProperty kAudioFilePropertyPacketSizeUpperBound code: -50()



3.

类初始化与全局变量


- (void)start;

start 为入口函数


- (void)stop

stop 为结束函数

在初始化方法中监听 外部的 通知,进行

stop

的调用

- (instancetype)init {
    self = [super init];
    _aspds = NULL;
    _isDone = FALSE;
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(stop) name:JBStopNotification object:nil];
    return  self;
}

类声明的变量

@interface JBPlayLocalMusicFile()
{
@public
	//这些C的struct 并没有 get set 方法,不能设置为 property, 所以直接设置为成员变量
    AudioFileID _audioFile;
    AudioQueueRef _mQueue;
    AudioStreamPacketDescription *_aspds; //从文件中读取的包秒数,每次读取后将其传入 AudioQueue中,以便能正确解码和播放
}

@property (nonatomic, assign) AudioStreamBasicDescription mASBD;
@property (nonatomic, assign) BOOL isDone; //是否播放完毕
@property (nonatomic, assign) UInt32 byteSizeInBuffer; //缓冲区应有的字节数(0.5秒内)
@property (nonatomic, assign) UInt32 packetsNumInBuffer; // 缓冲区应对应的数据包数量(0.5秒内)
@property (nonatomic, assign) Float64 readOffsetOfPackets; //读取了多少 packets
@end



4. 打开本地音频文件


start

函数内首先调用

openAudioFile

,

_audioFile

为声明的

@public

的成员变量


AudioFileID _audioFile;


这里我们使用

AudioFileOpenURL

函数 只读权限打开

flac

类型的音频,并绑定到

_audioFile

的指针句柄中,以后我们操作 这个文件就通过这个全局成员变量进行控制。

//打开 音频 文件
- (void)openAudioFile{
    NSURL *audioURL  = [[NSBundle mainBundle] URLForResource:@"G_E_M_ 邓紫棋 - 句号" withExtension:@"flac"];
    OSStatus status =  AudioFileOpenURL((__bridge  CFURLRef)audioURL, kAudioFileReadPermission, 0, &(_audioFile));
    printErr(@"AudioFileOpenURL", status);
}



5. 获取音频文件里面的 基本流信息

在上一步中我们打开了 音频文件,并获取到了指向它的句柄,现在我们操控

_audioFile

, 从文件中获取

AudioStreamBasicDescription

的信息。


AudioStreamBasicDescription

作为 Apple 描述音频流的基本格式,里面的值的含义就不具体说明了,调用

[JBHelper printASBD:asbd];

这个辅助函数能够打印处 ASBD 的具体值信息。

值得说明的是,由于我们读取的是

flac

格式,不是

PCM

这种线性的裸数据, 所以某些值是空的以0表示,因为可能是VBR(可变比特率)等,需要我们在后面的步骤进行进一步提取。

在这个方法中中我们 将取出的

AudioStreamBasicDescription

保存到全局变量

self.mASBD

中。

- (void)getASBDInFile {
    /***
     mp3 flac 文件格式, 和PCM 有点差别
2023-07-21 14:58:34.893822+0800 CoreAudioDemo[3193:5836480] planar:false bitsPerchannel:0
2023-07-21 14:58:34.894105+0800 CoreAudioDemo[3193:5836480] flags: 
	kAppleLosslessFormatFlag_16BitSourceData
	kAudioFormatFlagIsFloat
2023-07-21 14:58:34.894247+0800 CoreAudioDemo[3193:5836480] 
ASBD: 
	mSampleRate = 44100
	mFormatID = 1718378851(flac)
	mFormatFlags = 1
	mBytesPerPacket = 0
	mFramesPerPacket = 4096
	mBytesPerFrame = 0
	mChannelsPerFrame = 2
	mBitsPerChannel = 0
	mReserved = 0
     */
    AudioStreamBasicDescription asbd;
    UInt32 asbdSize = sizeof(asbd);
    OSStatus status = AudioFileGetProperty(_audioFile, kAudioFilePropertyDataFormat, &asbdSize, &asbd);
    printErr(@"AudioFileOpenURL", status);
    [JBHelper printASBD:asbd];
    self.mASBD = asbd;
}



6. 初始化

AudioQueue

在获取了文件中的

ASBD

后,我们就可以初始化

AudioQueue

了。

  • 第一个参数为 我们在上一步获取的

    _mASBD

    的引用
  • 第二个参数为 回调函数的名称,实际上

    AudioQueue

    会在合适的时候 自动对该函数进行回调,这也是

    C

    语言常用的回调模式,该回调我们会在 后面进行详细介绍。
  • 第三个参数为 第二参数回调函数 里面作为参数回传回来的值,这样我们可以在回调函数回调回来的时候访问我们特定的类的实例,这里桥接成

    void *

    传入
  • 第四第五为 特定的

    Runloop

    模式的值,可以不指定
  • 第六参数,为预留参数,只能传

    0
  • 最后一个参数

    _mQueue

    传入全局变量

    AudioQueueRef

    的地址, 只有传入上一级的地址,才能改变当前的地址,二级指针的操作。

这样我们把 作为 输出

Output

模式的

AudioQueue

的回调函数绑定好了,并且将

AudioQueue

也初始化了,

    //init audio queue by output param
    OSStatus status = AudioQueueNewOutput(&_mASBD,
                                          jbAudioQueueOutputCallback,
                                          (__bridge void *)self,
                                          NULL,
                                          NULL,
                                          0,
                                          &_mQueue);
    printErr(@"AudioQueueNewOutput", status);



7. 将文件中的

Encoder Magic Cookie

拷贝到 音频队列中



magic cookie



CoreAudio

表示附加到音频流中的元数据(metadata), 能够为正常的解码文件和流提供必要的信息。

某些音频格式的文件,在播放过程中必须要有

Magic Cookie

的数据才能正常的解码播放。

经本地测试

mp3

文件读取不出

Magic Cookie

, 而

flac

格式的文件能够正常读取出来。

所以我们这里 会尝试读取一次,读取不到就 返回,如果能够读取到就直接传入

AudioQueue

中去。

具体过程详看代码和注释

// MP3获取不到 magic cookie
- (void)setFileMetaDataToAudioQueue{
    //先获取长度
    UInt32 cookieDataSize = 0;
    UInt32 isWriteAble = 0;
    //注意这里是AudioFileGetPropertyInfo, 获取长度和是否可以写
    OSStatus status = AudioFileGetPropertyInfo(_audioFile, kAudioFilePropertyMagicCookieData, &cookieDataSize, &isWriteAble);
    
    //有些没有 magic cookie ,所以不管
    if (status != noErr) {
        NSLog(@"magic cookie 不存在,忽略掉");
        return;
    }
    
    if (cookieDataSize <= 0) {
        NSLog(@"AudioFileGetPropertyInfo kAudioFilePropertyMagicCookieData get zero size data");
        return;
    }
    
    //根据长度获取对应的magic data 的内容
    Byte *cookieData = malloc(cookieDataSize *sizeof(Byte));
    //这里是AudioFileGetProperty
    status = AudioFileGetProperty(_audioFile, kAudioFilePropertyMagicCookieData, &cookieDataSize, cookieData);
    printErr(@"AudioFileGetProperty kAudioFilePropertyMagicCookieData", status);
    
    //将获取的MagicCookie 设置到 AudioQueue 中
    status = AudioQueueSetProperty(_mQueue, kAudioQueueProperty_MagicCookie, cookieData, cookieDataSize);
    printErr(@"AudioQueueSetProperty kAudioQueueProperty_MagicCookie", status);
    
    // malloc 后必须 free
    free(cookieData);
}



8.

计算 每次读取 需要的大小和包的数量

我们现在开始计算 每个缓冲区的大小和包的数量

这里我们在代码中 为 两个全局变量赋值

@property (nonatomic, assign) UInt32 byteSizeInBuffer; //缓冲区应有的字节数(0.5秒内)
@property (nonatomic, assign) UInt32 packetsNumInBuffer; // 缓冲区应对应的数据包数量(0.5秒内)

了解了这个函数是为获取上面两个值后,就可以带着目的的去看里面的函数了。

主要是考虑到

_mASBD.mBytesPerPacket

是否为 0 的两种 case,所以代码比较复杂,需要进行两种判断。

- (void)calculateSizeOfTime{
    
    //获取kBufferDurationInSeconds时间的包的数量
    UInt32 totalNumerOfPackets = 0;
    if (_mASBD.mFramesPerPacket > 0) {
        //每次时间间隔内需要收集的样本数量
        Float64 totalNumberOfSamples =  _mASBD.mSampleRate * kBufferDurationInSeconds;
        UInt32 totalNumberOfFrames = ceil(totalNumberOfSamples); //将数据向上取整
        totalNumerOfPackets = totalNumberOfFrames / _mASBD.mFramesPerPacket;
    } else {
        // 如果mFramesPerPacket==0,则编解码器在给定时间内没有可预测的数据包大小。
        // 在这种情况下,我们将假设在给定持续时间内最多有 1 个数据包来调整缓冲区大小
        totalNumerOfPackets = 1;
    }
    
    UInt32 packetSizeUpperBound = 0;
    UInt32 packetSizeUpperBoundSize = sizeof(packetSizeUpperBound);
    // 获取 计算出来的 理论上的 最大 package 大小。非读取文件
    OSStatus status = AudioFileGetProperty(_audioFile,
                                           kAudioFilePropertyPacketSizeUpperBound,
                                           &packetSizeUpperBoundSize,
                                           &packetSizeUpperBound);
    printErr(@"AudioFileGetProperty kAudioFilePropertyPacketSizeUpperBound", status);
    
    if (_mASBD.mBytesPerPacket > 0) {
        //设置具体值
        self.byteSizeInBuffer = self.mASBD.mBytesPerPacket * totalNumerOfPackets;
    } else {
        // 获取理论上最大值
        self.byteSizeInBuffer = packetSizeUpperBound * totalNumerOfPackets;
    }
    
    //定义一个最大值,以避免 RAM 消耗过大
    //并定义一个最小值,以确保我们有一个可以在播放时没有问题的缓冲区。太小了会频繁连续从文件读取 IO 消耗比较大
    const int maxBufferSize = 0x100000; // 128KB
    const int minBufferSize = 0x4000;  // 16KB
    //调整成一个中间的适合的值
    if(self.byteSizeInBuffer > maxBufferSize) {
        self.byteSizeInBuffer = maxBufferSize;
    } else if (self.byteSizeInBuffer < minBufferSize) {
        self.byteSizeInBuffer = minBufferSize;
    }
    
    //调整后重新计算大小, 这样可能多分配内存,但至少不会内存越界
    self.packetsNumInBuffer = self.byteSizeInBuffer / packetSizeUpperBound;
}



9. 获取额外的

AudioStreamPacketDescription

信息

由于我们是非 PCM 的数据,所以获取了上面的数据后,还不能知己开始数据采集,还需要 获取 除了

AudioStreamBasicDescription

这种音频流信息外,还需要获取

Packet

的基本信息。


_aspds



AudioStreamPacketDescription

的数组, 包含了

_packetsNumInBuffer

个元素

具体见代码和注释

/**
 如果音频基本流描述没有告诉任何有关每个数据包的字节数或每个数据包的帧的信息,
 那么我们就会遇到 VBR 编码或通道大小不等的 CBR 的情况。
 在任何这些情况下,我们都必须为额外的数据包描述分配缓冲区,
 这些描述将在处理音频文件并将其数据包读入缓冲区时填充。
 */
- (void)allocPacketArray {
    BOOL isNeedASPD = _mASBD.mBytesPerPacket == 0 || _mASBD.mFramesPerPacket == 0;
    if(isNeedASPD) {
    	//calloc能够将 开辟的内存自动 设为0
        _aspds = (AudioStreamPacketDescription *)calloc(sizeof(AudioStreamPacketDescription), _packetsNumInBuffer);
    } else {
        _aspds = NULL;
    }
}



10. 开辟AudioQueue 队列

在我们前面完成一些必要的数据计算和AudioQueue的配置,现在可以进行AudioQueue的内存申请和配置了。

我们这里使用了

kNumberBuffer

3个缓冲区,并将其

buffers

保存到这个数组里面,注意

buffers

不使用引用计数,所以在函数返回后并不会析构,所以这个使用的局部变量。

  • 这里我们使用了

    AudioQueueAllocateBuffer

    来开辟了

    self.byteSizeInBuffer

    这么多

    Byte

    的内存,并关联到

    _mQueue

    中,并对内存里面的值进行合适的赋值。最终将开辟的内存的首地址保存到

    buffers[i]

    变量中

  • jbAudioQueueOutputCallback

    这里开辟内存后手动调用了一下这个回调函数,是为了在回调中读取一次

    0.5

    秒的文件数据,来进行填充我们刚刚开辟出来的内存。

  • self.isDone

    代表着,上一步回调中就把文件的数据读完了,或者出错了,直接break,然后再 上一级方法中 调用

    stop

    函数。

三次循环,代表着开辟出的三个缓冲队列都按我们的要求,填充完毕。也意味着我们现在的内存中包含了 0.5 * 3 = 1.5秒的数据。是时候在下一步调用

Audio start

方法,正式开始播放了.

- (void)allocAudioQueue {
    AudioQueueBufferRef buffers[kNumberBuffer];
    
    OSStatus status = noErr;
    for(int i = 0 ; i< kNumberBuffer; i++) {
        status = AudioQueueAllocateBuffer(_mQueue,
                                          self.byteSizeInBuffer,
                                          &buffers[i]);
        printErr(@"AudioQueueAllocateBuffer", status);
        
        //手动调用回调,用音频文件中的音频数据填充缓冲区。
        //后续调用AudioQueueStart后,会自动触发回调进行调用
        jbAudioQueueOutputCallback((__bridge  void *)self, _mQueue, buffers[i]);
        if (self.isDone) {
            //回调函数中设置为true后,代表剩余时间小于1.5秒
            break;
        }
    }
}



11. 正式播放

前面所有的都配置齐全后就可以直接启动 音频队列了。这时候就可以在默认扬声器里面听到我们的音乐声音了。

启动后 音频队列会先消耗上一步,我们手动塞入队列缓冲区的样本。

然后会在特定的时间回调调用

jbAudioQueueOutputCallback

这个函数。

status = AudioQueueStart(_mQueue, NULL);
printErr(@"AudioQueueStart", status);



12. 回调函数

我们前面所有的都讲了,现在开始理一理回调函数里面究竟做了啥。

首先定义这个函数使用了C的Static 静态函数。


  • inUserData

    形参为我们在

    AudioQueueNewOutput

    里面传入的参数,这个原封不动的回传了回来

  • inAQ

    对音频队列的引用, 我们不关心是3个队里中的哪一个,系统会自动调度

  • inBuffer

    我们将文件中的音频数据读取后,需要将数据写入这个内存区域,然后系统会自动解码播放。

这里的主要流程为:

  • 获取

    JBPlayLocalMusicFile

    实例对象

  • AudioFileReadPacketData

    从文件中读取音频数据,并会填充

    _aspds

    这个音频格式。最后

    numberBytes

    ,

    numberPackets

    这两个本地变量的值会被改变成实际本地从文件里面读取的长度和包数量
  • 如果前面无误的话,需要将上一步获取的数据,通过调用

    AudioQueueEnqueueBuffer

    填充到 音频队列的缓冲区中,音频队列会按照以前设置的音频流格式,和本地包的特定的

    _aspds

    数据进行综合解码和播放。
static void jbAudioQueueOutputCallback(void * inUserData,
                                       AudioQueueRef inAQ, //对音频队列的引用
                                       AudioQueueBufferRef inBuffer //需要填充的缓冲区播放数据的引用
) {
    
    JBPlayLocalMusicFile *playClass = (__bridge JBPlayLocalMusicFile *)inUserData;
    if (playClass->_isDone) {
        return;
    }
    
    // 存在局部变量中后,read数据的时候会 自动更新读取到的值
    UInt32 numberBytes = playClass.byteSizeInBuffer;
    UInt32 numberPackets = playClass.packetsNumInBuffer;
    
    //读取音频包内容,并在最后一个字段中将读取到的数据填充到 inBuffer 中去
    OSStatus status = AudioFileReadPacketData(playClass->_audioFile,
                                              false,
                                              &numberBytes,
                                              playClass->_aspds,
                                              playClass.readOffsetOfPackets,
                                              &numberPackets,
                                              inBuffer->mAudioData);
    printErr(@"AudioFileReadPacketData", status);
    if (numberBytes <= 0 || numberPackets <= 0) {
        NSLog(@"数据读取完毕");
        playClass->_isDone = true;
        dispatch_async(dispatch_get_main_queue(), ^{
            [playClass stop];
        });
        return;
    }
    
    inBuffer->mAudioDataByteSize = numberBytes;
    AudioQueueEnqueueBuffer(inAQ,
                            inBuffer,
                            (playClass->_aspds ? numberPackets : 0),
                            playClass->_aspds);
    //消费完后,更新下次需要读取的文件的位置
    playClass.readOffsetOfPackets += numberPackets;
}



13. 停止音频

意外停止和正常播完停止都需要调用这个函数。


  • AudioQueueStop

    停止队列

  • AudioQueueDispose

    处理音频队列,这里会处置其中的资源和缓冲区等,包括里面会自动调用

    AudioQueueAllocateBuffer

    对应的析构函数

    AudioQueueFreeBuffer

  • _aspds

    如果有,就释放

  • AudioFileClose

    最后关闭文件。
- (void)stop {
    
    OSStatus status = AudioQueueStop(_mQueue, true);
    printErr(@"AudioQueueStop", status);
    status = AudioQueueDispose(_mQueue, true);
    printErr(@"AAudioQueueDispose", status);
    if(_aspds) {
        free(_aspds);
    }
    status = AudioFileClose(_audioFile);
    printErr(@"AudioFileClose", status);
    self.isDone = true;
    NSLog(@"播放结束");
}

本文地址: https://blog.csdn.net/goldWave01/article/details/131834259 😄



版权声明:本文为goldWave01原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。