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

macOS的音频模块使用还是和 iOS有细微差别的。

今天记录是的是 使用 AudioQueue 配合 AudioFile 进行播放macOS 本地音频文件

本文打仓库代码为: JBPlayLocalMusicFile.m

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

  • AudioQueue 位于 <AudioToolbox/AudioQueue.h>, 作为输出模块,输入音频到系统默认扬声器
  • AudioFile 位于 <AudioToolbox/AudioFile.h>, 读取本地音频文件,然后将读取的Buffer塞入 AudioQueue的音频队列中,等待系统扬声器宠幸。

读取和播放的大致流程为:

1. 前置说明

全局设置两个值分别为

c 复制代码
//每个缓冲区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输出,

c 复制代码
#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的调用

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

类声明的变量

objectivec 复制代码
@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 的指针句柄中,以后我们操作 这个文件就通过这个全局成员变量进行控制。

objectivec 复制代码
//打开 音频 文件
- (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 中。

objectivec 复制代码
- (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也初始化了,

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

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

某些音频格式的文件,在播放过程中必须要有 Magic Cookie 的数据才能正常的解码播放。

经本地测试 mp3文件读取不出Magic Cookie , 而flac格式的文件能够正常读取出来。

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

具体过程详看代码和注释

objectivec 复制代码
// 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. 计算 每次读取 需要的大小和包的数量

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

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

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

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

主要是考虑到 _mASBD.mBytesPerPacket 是否为 0 的两种 case,所以代码比较复杂,需要进行两种判断。

objectivec 复制代码
- (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 的基本信息。
_aspdsAudioStreamPacketDescription 的数组, 包含了 _packetsNumInBuffer 个元素

具体见代码和注释

objectivec 复制代码
/**
 如果音频基本流描述没有告诉任何有关每个数据包的字节数或每个数据包的帧的信息,
 那么我们就会遇到 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的内存申请和配置了。

我们这里使用了kNumberBuffer3个缓冲区,并将其buffers 保存到这个数组里面,注意buffers 不使用引用计数,所以在函数返回后并不会析构,所以这个使用的局部变量。

  • 这里我们使用了AudioQueueAllocateBuffer 来开辟了 self.byteSizeInBuffer 这么多Byte的内存,并关联到_mQueue中,并对内存里面的值进行合适的赋值。最终将开辟的内存的首地址保存到 buffers[i] 变量中
  • jbAudioQueueOutputCallback 这里开辟内存后手动调用了一下这个回调函数,是为了在回调中读取一次 0.5 秒的文件数据,来进行填充我们刚刚开辟出来的内存。
  • self.isDone 代表着,上一步回调中就把文件的数据读完了,或者出错了,直接break,然后再 上一级方法中 调用stop 函数。

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

objectivec 复制代码
- (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 这个函数。

objectivec 复制代码
status = AudioQueueStart(_mQueue, NULL);
printErr(@"AudioQueueStart", status);

12. 回调函数

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

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

  • inUserData形参为我们在AudioQueueNewOutput里面传入的参数,这个原封不动的回传了回来
  • inAQ对音频队列的引用, 我们不关心是3个队里中的哪一个,系统会自动调度
  • inBuffer我们将文件中的音频数据读取后,需要将数据写入这个内存区域,然后系统会自动解码播放。

这里的主要流程为:

  • 获取JBPlayLocalMusicFile 实例对象
  • AudioFileReadPacketData从文件中读取音频数据,并会填充_aspds这个音频格式。最后numberBytes,numberPackets这两个本地变量的值会被改变成实际本地从文件里面读取的长度和包数量
  • 如果前面无误的话,需要将上一步获取的数据,通过调用AudioQueueEnqueueBuffer 填充到 音频队列的缓冲区中,音频队列会按照以前设置的音频流格式,和本地包的特定的_aspds数据进行综合解码和播放。
objectivec 复制代码
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最后关闭文件。
objectivec 复制代码
- (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 😄

相关推荐
Java小白笔记4 小时前
Mac中安装homebrew
macos
HerayChen7 小时前
HbuildderX运行到手机或模拟器的Android App基座识别不到设备 mac
android·macos·智能手机
hairenjing11237 小时前
在 Android 手机上从SD 卡恢复数据的 6 个有效应用程序
android·人工智能·windows·macos·智能手机
小李飞刀李寻欢10 小时前
Mac电脑如何解压rar压缩包
macos·rar·解压
Java小白笔记10 小时前
Mac中禁用系统更新
macos
AndyFrank10 小时前
mac crontab 不能使用问题简记
linux·运维·macos
Mac新人10 小时前
一招解决Mac没有剪切板历史记录的问题
macos·mac
王拴柱10 小时前
Mac保护电池健康,延长电池使用寿命的好方法
macos·mac
daa2010 小时前
macos中安装和设置ninja
macos
Java小白笔记11 小时前
Mac解决 zsh: command not found: ll
macos