使用 Core Audio 实时生成正弦音

Realtime sine tone generation with Core Audio

我想使用苹果核心音频框架创建一个实时正弦发生器。我想做低水平的事情,这样我就可以学习和理解基础知识。

我知道使用 PortAudio 或 Jack 可能会更容易,我会在某个时候使用它们,但我想先让它工作,这样我就有信心了解基础知识。

我就这个话题搜索了几天,但似乎没有人使用核心音频创建实时波发生器,试图在使用 C 而不是 Swift 或 [=100= 时获得低延迟].

为此,我使用了我不久前建立的一个项目。它最初被设计成一个游戏。所以Application启动后,会进入一个运行循环。我认为这非常适合,因为我可以使用主循环将样本复制到音频缓冲区并处理渲染和输入处理。

到目前为止我能听到声音。有时它工作了一段时间然后开始出现故障,有时它立即出现故障。

这是我的代码。如果只显示重要部分,我会尽量简化。

我有多个问题。它们位于 post.

的底部

应用程序主要 运行 循环。这是创建 window 并初始化缓冲区和内存后一切开始的地方:

    while (OSXIsGameRunning())
    {
       OSXProcessPendingMessages(&GameData);            

       [GlobalGLContext makeCurrentContext];

       CGRect WindowFrame = [window frame];
       CGRect ContentViewFrame = [[window contentView] frame];

       CGPoint MouseLocationInScreen = [NSEvent mouseLocation];
       BOOL MouseInWindowFlag = NSPointInRect(MouseLocationInScreen, WindowFrame);
       CGPoint MouseLocationInView = {};

       if (MouseInWindowFlag)
       {
          NSRect RectInWindow = [window convertRectFromScreen:NSMakeRect(MouseLocationInScreen.x,                                                                        MouseLocationInScreen.y,                                                                 1,                                                                         1)];
          NSPoint PointInWindow = RectInWindow.origin;
          MouseLocationInView= [[window contentView] convertPoint:PointInWindow fromView:nil];
       }
       u32 MouseButtonMask = [NSEvent pressedMouseButtons];

       OSXProcessFrameAndRunGameLogic(&GameData, ContentViewFrame,
                                           MouseInWindowFlag, MouseLocationInView,
                                           MouseButtonMask);

#if ENGINE_USE_VSYNC
       [GlobalGLContext flushBuffer];
#else        
       glFlush();
#endif

     }

通过使用 VSYNC,我可以将循环降低到 60 FPS。时间不是很紧,但很稳定。我还有一些代码可以使用更加不精确的马赫计时手动限制它。为了便于阅读,我把它留了下来。 不使用 VSYNC 或使用 mach 计时来获得每秒 60 次迭代也会导致音频故障。

计时日志:

CyclesElapsed: 8154360866, TimeElapsed: 0.016624, FPS: 60.155666
CyclesElapsed: 8174382119, TimeElapsed: 0.020021, FPS: 49.946926
CyclesElapsed: 8189041370, TimeElapsed: 0.014659, FPS: 68.216309
CyclesElapsed: 8204363633, TimeElapsed: 0.015322, FPS: 65.264511
CyclesElapsed: 8221230959, TimeElapsed: 0.016867, FPS: 59.286217
CyclesElapsed: 8237971921, TimeElapsed: 0.016741, FPS: 59.733719
CyclesElapsed: 8254861722, TimeElapsed: 0.016890, FPS: 59.207333
CyclesElapsed: 8271667520, TimeElapsed: 0.016806, FPS: 59.503273
CyclesElapsed: 8292434135, TimeElapsed: 0.020767, FPS: 48.154209

这里重要的是函数OSXProcessFrameAndRunGameLogic。它每秒被调用 60 次,并传递给它包含基本信息的结构,如渲染缓冲区、键盘状态和声音缓冲区,如下所示:

    typedef struct osx_sound_output
    {
       game_sound_output_buffer SoundBuffer;
       u32 SoundBufferSize;
       s16* CoreAudioBuffer;
       s16* ReadCursor;
       s16* WriteCursor;

       AudioStreamBasicDescription AudioDescriptor;
       AudioUnit AudioUnit;  
    } osx_sound_output;

其中 game_sound_output_buffer 是:

    typedef struct game_sound_output_buffer
    {
       real32 tSine;
       int SamplesPerSecond;
       int SampleCount;
       int16 *Samples;
    } game_sound_output_buffer;

这些在应用程序进入其 运行 循环之前设置。 SoundBuffer 本身的大小是 SamplesPerSecond * sizeof(uint16) * 2 其中 SamplesPerSecond = 48000.

所以里面OSXProcessFrameAndRunGameLogic就是声音的产生:

void OSXProcessFrameAndRunGameLogic(osx_game_data *GameData, CGRect WindowFrame,
                                    b32 MouseInWindowFlag, CGPoint MouseLocation,
                                    int MouseButtonMask)
{
    GameData->SoundOutput.SoundBuffer.SampleCount = GameData->SoundOutput.SoundBuffer.SamplesPerSecond / GameData->TargetFramesPerSecond;

    // Oszi 1

    OutputTestSineWave(GameData, &GameData->SoundOutput.SoundBuffer, GameData->SynthesizerState.ToneHz);

    int16* CurrentSample = GameData->SoundOutput.SoundBuffer.Samples;
    for (int i = 0; i < GameData->SoundOutput.SoundBuffer.SampleCount; ++i)
    {
        *GameData->SoundOutput.WriteCursor++ = *CurrentSample++;
        *GameData->SoundOutput.WriteCursor++ = *CurrentSample++;

        if ((char*)GameData->SoundOutput.WriteCursor >= ((char*)GameData->SoundOutput.CoreAudioBuffer + GameData->SoundOutput.SoundBufferSize))
        {
            //printf("Write cursor wrapped!\n");
            GameData->SoundOutput.WriteCursor  = GameData->SoundOutput.CoreAudioBuffer;
        }
    }
}

其中OutputTestSineWave是缓冲区实际填充数据的部分:

void OutputTestSineWave(osx_game_data *GameData, game_sound_output_buffer *SoundBuffer, int ToneHz)
{
    int16 ToneVolume = 3000;
    int WavePeriod = SoundBuffer->SamplesPerSecond/ToneHz;

    int16 *SampleOut = SoundBuffer->Samples;
    for(int SampleIndex = 0;
        SampleIndex < SoundBuffer->SampleCount;
        ++SampleIndex)
    {
        real32 SineValue = sinf(SoundBuffer->tSine);
        int16 SampleValue = (int16)(SineValue * ToneVolume);

        *SampleOut++ = SampleValue;
        *SampleOut++ = SampleValue;

        SoundBuffer->tSine += Tau32*1.0f/(real32)WavePeriod;
        if(SoundBuffer->tSine > Tau32)
        {
            SoundBuffer->tSine -= Tau32;
        }
    }
}

因此,当在启动时创建缓冲区时,也会初始化核心音频,我这样做是这样的:

void OSXInitCoreAudio(osx_sound_output* SoundOutput)
{
    AudioComponentDescription acd;
    acd.componentType         = kAudioUnitType_Output;
    acd.componentSubType      = kAudioUnitSubType_DefaultOutput;
    acd.componentManufacturer = kAudioUnitManufacturer_Apple;

    AudioComponent outputComponent = AudioComponentFindNext(NULL, &acd);

    AudioComponentInstanceNew(outputComponent, &SoundOutput->AudioUnit);
    AudioUnitInitialize(SoundOutput->AudioUnit);

    // uint16
    //AudioStreamBasicDescription asbd;
    SoundOutput->AudioDescriptor.mSampleRate       = SoundOutput->SoundBuffer.SamplesPerSecond;
    SoundOutput->AudioDescriptor.mFormatID         = kAudioFormatLinearPCM;
    SoundOutput->AudioDescriptor.mFormatFlags      = kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsNonInterleaved | kAudioFormatFlagIsPacked;
    SoundOutput->AudioDescriptor.mFramesPerPacket  = 1;
    SoundOutput->AudioDescriptor.mChannelsPerFrame = 2; // Stereo
    SoundOutput->AudioDescriptor.mBitsPerChannel   = sizeof(int16) * 8;
    SoundOutput->AudioDescriptor.mBytesPerFrame    = sizeof(int16); // don't multiply by channel count with non-interleaved!
    SoundOutput->AudioDescriptor.mBytesPerPacket   = SoundOutput->AudioDescriptor.mFramesPerPacket * SoundOutput->AudioDescriptor.mBytesPerFrame;



    AudioUnitSetProperty(SoundOutput->AudioUnit,
                         kAudioUnitProperty_StreamFormat,
                         kAudioUnitScope_Input,
                         0,
                         &SoundOutput->AudioDescriptor,
                         sizeof(SoundOutput->AudioDescriptor));

    AURenderCallbackStruct cb;
    cb.inputProc = OSXAudioUnitCallback;
    cb.inputProcRefCon = SoundOutput;

    AudioUnitSetProperty(SoundOutput->AudioUnit,
                         kAudioUnitProperty_SetRenderCallback,
                         kAudioUnitScope_Global,
                         0,
                         &cb,
                         sizeof(cb));

    AudioOutputUnitStart(SoundOutput->AudioUnit);
}

核心音频的初始化代码将渲染回调设置为OSXAudioUnitCallback

OSStatus OSXAudioUnitCallback(void * inRefCon,
                              AudioUnitRenderActionFlags * ioActionFlags,
                              const AudioTimeStamp * inTimeStamp,
                              UInt32 inBusNumber,
                              UInt32 inNumberFrames,
                              AudioBufferList * ioData)
{
#pragma unused(ioActionFlags)
#pragma unused(inTimeStamp)
#pragma unused(inBusNumber)

    //double currentPhase = *((double*)inRefCon);

    osx_sound_output* SoundOutput = ((osx_sound_output*)inRefCon);


    if (SoundOutput->ReadCursor == SoundOutput->WriteCursor)
    {
        SoundOutput->SoundBuffer.SampleCount = 0;
        //printf("AudioCallback: No Samples Yet!\n");
    }

    //printf("AudioCallback: SampleCount = %d\n", SoundOutput->SoundBuffer.SampleCount);

    int SampleCount = inNumberFrames;
    if (SoundOutput->SoundBuffer.SampleCount < inNumberFrames)
    {
        SampleCount = SoundOutput->SoundBuffer.SampleCount;
    }

    int16* outputBufferL = (int16 *)ioData->mBuffers[0].mData;
    int16* outputBufferR = (int16 *)ioData->mBuffers[1].mData;

    for (UInt32 i = 0; i < SampleCount; ++i)
    {
        outputBufferL[i] = *SoundOutput->ReadCursor++;
        outputBufferR[i] = *SoundOutput->ReadCursor++;

        if ((char*)SoundOutput->ReadCursor >= (char*)((char*)SoundOutput->CoreAudioBuffer + SoundOutput->SoundBufferSize))
        {
            //printf("Callback: Read cursor wrapped!\n");
            SoundOutput->ReadCursor = SoundOutput->CoreAudioBuffer;
        }
    }

    for (UInt32 i = SampleCount; i < inNumberFrames; ++i)
    {
        outputBufferL[i] = 0.0;
        outputBufferR[i] = 0.0;
    }

    return noErr;
}

这就是它的全部内容。这很长,但我没有找到以更紧凑的方式呈现所有需要的信息的方法。我想展示所有,因为我绝不是专业的程序员。如果您觉得有什么不足,请告诉我。

我的感觉告诉我时间有问题。我觉得函数 OSXProcessFrameAndRunGameLogic 有时需要更多时间,以便核心音频回调在 OutputTestSineWave.

完全写入之前已经从缓冲区中提取样本

OSXProcessFrameAndRunGameLogic 实际上还有更多我没有在这里展示的内容。我将 "software rendering" 非常基本的东西放入帧缓冲区,然后由 OpenGL 显示,我也在那里进行按键检查,因为是的,它是功能的主要功能。将来我想在这里处理多个振荡器、滤波器和其他东西的控制。 无论如何,即使我停止在每次迭代中调用渲染和输入处理,我仍然会遇到音频故障。

我尝试将 OSXProcessFrameAndRunGameLogic 中的所有声音处理拉入一个自己的函数 void* RunSound(void *GameData) 并将其更改为:

pthread_t soundThread;
pthread_create(&soundThread, NULL, RunSound, GameData);
pthread_join(soundThread, NULL);

但是我得到了不同的结果,甚至不确定多线程是否是这样完成的。每秒创建和销毁线程 60 次似乎不是可行的方法。

我还想在应用程序真正 运行 进入主循环之前让声音处理发生在一个完全不同的线程上。类似两个同时 运行ning while 循环,第一个处理音频,后者 UI 和输入。

问题:

  1. 我的音频有问题。渲染和输入似乎工作正常,但音频有时会出现故障,有时则不会。从我提供的代码中,你能看出我做错了什么吗?
  2. 我是否以错误的方式使用核心音频技术来实现实时低延迟信号生成?
  3. 我应该像上面所说的那样在单独的线程中进行声音处理吗?如何正确完成这种情况下的线程处理?有一个专用于声音的线程是有意义的吗?
  4. 基本的音频处理不应该放在核心音频的渲染回调中,我说的对吗?此功能是否仅用于输出提供的声音缓冲区? 如果声音处理应该在这里完成,我如何从回调内部访问键盘状态等信息?
  5. 是否有任何我可能错过的资源可以指给我看?

这是我所知道的唯一可以就此项目获得帮助的地方。非常感谢您的帮助。

如果您不清楚,请告诉我。

谢谢:)

一般来说,在处理低延迟音频时,您希望获得尽可能确定的行为。

例如,这可以转换为:

  • 不要在音频线程上持有任何锁(优先级反转)
  • 音频线程上没有内存分配(通常需要太多时间)
  • 音频线程上没有 file/network IO(通常需要太多时间)

问题 1:

当您想实现连续、实时、无故障的音频时,您的代码确实存在一些问题。

1.两个不同的时钟域。
您提供的音频数据来自(我所说的)与请求数据的时钟域不同的时钟域。在这种情况下,时钟域 1 由您的 TargetFramesPerSecond 值定义,时钟域 2 由 Core Audio 定义。但是,由于调度的工作方式,您无法保证线程按时按时完成。您尝试将渲染目标定为每秒 n 帧,但是当您不按时进行时会发生什么?据我所知,您没有补偿渲染周期与理想时间相比的偏差。 线程的工作方式最终是由 OS 调度程序决定您的线程何时处于活动状态。永远没有保证,这会导致您渲染周期不是很精确(就音频渲染所需的精度而言)。

2。渲染线程和 Core Audio 渲染回调线程之间没有同步。
OSXAudioUnitCallback 运行 所在的线程与 OSXProcessFrameAndRunGameLogic 所在的线程不同,因此 OutputTestSineWave 运行。您正在从主线程提供数据,并且正在从 Core Audio 渲染线程读取数据。通常你会使用一些互斥体来保护你的数据,但在这种情况下这是不可能的,因为你会 运行 进入优先级反转的问题。 处理竞争条件的一种方法是使用一个缓冲区,该缓冲区使用原子变量来存储缓冲区的用法和指针,并且只让 1 个生产者和 1 个消费者使用该缓冲区。 这种缓冲区的好例子是:
https://github.com/michaeltyson/TPCircularBuffer https://github.com/andrewrk/libsoundio/blob/master/src/ring_buffer.h

3。您的音频渲染线程中有很多调用会阻止确定性行为。
正如您所写,您在同一个音频渲染线程中做了很多事情。变化非常大,将会有一些事情发生(在引擎盖下),这会阻止您的线程准时。通常,您应该避免花费太多时间或不确定的调用。对于所有 OpenGL/keypres/framebuffer 渲染,无法确定您的线程是否会 "arrive on time".
以下是一些值得研究的资源。

问题 2:

AFAICT 一般而言,您正在正确使用 Core Audio 技术。我认为你唯一的问题是在提供方面。

问题 3:

是的。确实!虽然,有多种方法可以做到这一点。 在您的情况下,您有一个正常优先级的线程 运行 进行渲染和一个高性能的实时线程,在该线程上调用音频渲染回调。查看您的代码,我建议将正弦波的生成放在渲染回调函数中(或从渲染回调中调用 OutputTestSineWave)。这样你就可以在可靠的高优先级线程中生成音频 运行,没有其他渲染会干扰计时精度,也不需要环形缓冲区。

在需要进行 "non-realtime" 处理以准备好音频数据的其他情况下(考虑从文件读取、从网络读取甚至从其他物理音频设备读取),您不能 运行这个逻辑在 Core Audio 线程中。解决这个问题的一种方法是启动一个单独的专用线程来执行此处理。要将数据传递到实时音频线程,您将使用前面提到的环形缓冲区。 它基本上归结为两个简单的目标:对于实时线程,有必要让音频数据始终可用(所有渲染调用),如果失败,您将最终发送无效(或更好地归零)音频数据。 辅助线程的主要目标是尽快填满环形缓冲区并尽可能保持环形缓冲区满。因此,只要有空间将新的音频数据放入环形缓冲区,线程就应该这样做。

在这种情况下,ringbuffer 的大小将指示对延迟的容忍度。环形缓冲区的大小将在确定性(更大的缓冲区)和延迟(更小的缓冲区)之间取得平衡。

顺便说一句。我很确定 Core Audio 拥有为您完成这一切的所有设施。

问题 4:

有多种方法可以实现您的目标,在 Core Audio 的渲染回调中渲染内容绝对是其中之一。你应该记住的一件事是你必须及时确保功能returns。
要更改参数以操纵音频渲染,您必须找到一种传递消息的方法,使 reader(音频渲染器函数)无需锁定和等待即可获取消息。我这样做的方法是创建第二个环形缓冲区,其中包含音频渲染器可以使用的消息。这可以像一个环形缓冲区一样简单,它保存带有数据的结构(甚至是指向数据的指针)。只要你坚持不加锁的规则。

问题5:

我不知道您知道哪些资源,但这里有一些必读内容:
http://atastypixel.com/blog/four-common-mistakes-in-audio-development/
http://www.rossbencina.com/code/real-time-audio-programming-101-time-waits-for-nothing
https://developer.apple.com/library/archive/qa/qa1467/_index.html

你的基本问题是你试图从你的游戏循环中推送音频而不是让音频系统拉它;例如而不是总是有(或快速能够创建*)足够的音频样本准备好音频回调请求的音频回调量。 "always" 必须考虑足够的 slop 以覆盖游戏循环中的时间抖动(被调用晚或早或次数太少)。

(* 没有锁、信号量、内存分配或 Objective C 消息)