Audio Queue錄制 播放原理

閱讀前提:

  • C語言基礎
  • 音視頻基礎
  • Core Audio基本數據結構
  • Audio Session

Audio Queue Services是官方推薦的方式以一種直接的,低開銷的方式在iOS與Mac OS X中完成錄制與播放的操作.不像上層的API,它可以通過回調拿到音頻幀數據,以完成更加精細的操作.

使用場景:

比上層API而言,可以直接獲取每一幀音頻數據,因此可以對音頻幀做一些需要的處理. 但是無法對聲音做一些更加精細的處理,如回聲消除,混音,降噪等等,如果需要做更底層的操作,需要使用Audio Unit.

Overview

Audio Queue Service是Core Audio的Audio Toolbox框架中的基于C語言的一套接口.

Audio Queue Services是一套高級的API. 它不僅可以在無需了解硬件的基礎上使程序與音頻硬件(麥克風,揚聲器等)之間完成交互,也在無需了解編解碼器的原理情況下讓我們使用復雜的編解碼器.

同時,Audio Queue Services還提供了更加精細的定時控制以支持預定的播放與同步任務.可以使用它同步多個音頻播放隊列或者音視頻間進行同步.

支持以下格式

  • 線性PCM
  • Apple提供的本機支持的任何壓縮格式
  • 用戶使用編解碼器生成的任何格式

注意: Audio Queue Services是一套純C的接口,所以基礎的C,C++需要有一定了解.

1. Audio Queues概述

在iOS, Mac OS X中audio queue是一個軟件層面的對象,可以用來做錄制與播放操作.使用AudioQueueRef代表其數據結構.

作用

  • 連接音頻硬件
  • 管理相關模塊內存
  • 使用編解碼器
  • 調解錄制與播放

1.1. Audio Queue架構

  • 一組音頻隊列數據,隊列中每個結點都是音頻數據的臨時存儲庫.
  • 隊列中數據是嚴格按照順序排列
  • 回調函數

1.2. 錄制

如果要使用audio queue的錄制功能,通過AudioQueueNewInput創(chuàng)建錄音隊列.

1.record

錄制使用的audio queue的輸入端通常是當前設備連接的音頻設備,如內置的麥克風,或外置的帶麥克風功能的輸入設備.輸出端是我們定義的回調函數.如果將音頻數據錄制成文件,可以在回調函數中將從audio queue中取出的音頻數據寫入文件.當然錄制的音頻數據也可以直接送給當前App以實現(xiàn)邊錄制邊播放的功能.

每個audio queue,不管是用于錄制或播放,都至少有一個或多個音頻數據.所有的音頻數據被放在一個被稱為音頻隊列buffer特殊的數據結構中,可以理解成隊列中的結點.如上圖所示,指定數量的buffer按順序依次被放入音頻隊列中,它們最終也將在回調函數中按順序取出.

1.3. 播放

如果要使用audio queue的播放功能,通過AudioQueueNewOutput創(chuàng)建播放隊列對象.

2.play

播放使用的音頻隊列,回調函數在輸入端.該回調函數將從本地或其他音頻數據源獲取到的數據交給音頻隊列中.當沒有數據裝入播放回調函數也會告訴音頻隊列停止播放.

用于播放的音頻隊列的輸出端則連接著音頻輸出硬件,如揚聲器或外接的具有揚聲器功能的音頻設備(如:耳機,音響等).

1.4. 音頻隊列數據

AudioQueueBuffer用于存放音頻隊列數據.

typedef struct AudioQueueBuffer {
    const UInt32   mAudioDataBytesCapacity;
    void *const    mAudioData;
    UInt32         mAudioDataByteSize;
    void           *mUserData;
} AudioQueueBuffer;
typedef AudioQueueBuffer *AudioQueueBufferRef;
  • mAudioData: 當前取出的隊列中存放的即時的音頻數據指針,它指向真正存放音頻數據的內存地址.
  • mAudioDataBytesCapacity: 當前音頻數據最大存儲空間
  • mAudioDataByteSize: 當前存儲的音頻數據實際的大小
  • mUserData: 開發(fā)者可以存放一些自定義的數據

音頻隊列可以使用任意數量的音頻數據結點,但一般建議用3個即可.因為如果太少則存取過于頻繁,太多則增加應用程序內存消耗,正常情況下兩個即可,我們可以使用第三個當有延遲情況出現(xiàn)時作為補償數據.

因為Audio Queue的純C函數,內存需要我們手動管理.

  • 初始化Audio Queue時使用AudioQueueAllocateBuffer分配內存
  • 回調函數中用完時使用AudioQueueDispose回收內存

通過內存管理,可以使錄制播放更加穩(wěn)定,同時優(yōu)化App資源使用.

1.5. 音頻隊列與入隊操作

audio queue: 音頻隊列, 即Audio Queue Services的名字

audio queue buffer : 音頻隊列中存放一個或多個結點數據

  • 錄制過程

做錄制操作時,一個audio queue buffer將被從輸入設備(如:麥克風)采集的音頻數據填充.音頻隊列中剩余的buffer按順序排列在當前填充數據的buffer之后,依次等待被填充數據.在輸出端,回調函數將按照指定時間間隔依次接收音頻隊列中按順序排列好的音頻數據.工作原理如下圖:

3.recording_process

圖一: 錄制開始,音頻隊列中填充需要的音頻數據.

圖二: 第一個buffer被填充,對調函數取出buffer 1并將其寫入文件,同時buffer2也被填充完數據.

圖三: 在第4步,回調函數將用完的buffer 1重新放回音頻隊列,隨后第五步回調函數再次取出音頻數據buffer2,最終將其寫入文件而后重新放回音頻隊列此后循環(huán)往復直到錄制停止.

  • 播放過程

做播放操作時,一個audio queue buffer需要交給輸出設備(如:揚聲器).剩余的音頻數據也將按順序排列在當前取出播放的音頻數據之后,等待播放.回調函數將按順序取出音頻隊列中的數據交給揚聲器,隨后將用完的audio queue buffer重新放入音頻隊列.

4.playback_process

圖1: 應用程序啟動音頻播放隊列,每調用依次回調函數填充一個audio queue buffers,填充完后將其放入音頻隊列. 當應用程序調用AudioQueueStart立即開始播放.

圖2: 音頻隊列輸出第一個音頻數據

圖3: 用完的audio queue buffer重新放入音頻隊列.一旦播放了第一個音頻數據,音頻隊列會進入一個循環(huán)穩(wěn)定的狀態(tài),即開始播放下一個buffer2(第4步)然后調用回調函數準備填充數據(第5步),最后(第6步)buffer1重新被填充并裝入音頻隊列依次循環(huán)直到音頻隊列停止.

  • 控制播放的過程

Audio queue buffers始終按照入隊順序進行播放.然而可以使用AudioQueueEnqueueBufferWithParameters函數做一些額外控制

a. 設置緩沖區(qū)精確的播放時間,用于同步

b. 可以裁剪開始或結尾的audio queue buffer,這使我們可以做到開始或結尾的靜音效果.

c. 增加播放的聲音

后文播放章節(jié)中將具體介紹.

1.6. 回調函數

無論錄制還是播放,一旦注冊好回調函數,它將頻繁的被調用.調用時間取決于我們的設置.回調函數的一個重要職責是將用完的數據重新交給音頻隊列.使用AudioQueueEnqueueBuffer入隊.

1.6.1. 錄制的回調函數
AudioQueueInputCallback (
    void                               *inUserData,
    AudioQueueRef                      inAQ,
    AudioQueueBufferRef                inBuffer,
    const AudioTimeStamp               *inStartTime,
    UInt32                             inNumberPacketDescriptions,
    const AudioStreamPacketDescription *inPacketDescs
);

當輸入端采集到音頻數據時就會觸發(fā)回調,可以從回調函數中取出裝有音頻數據的audio queue buffer.

  • inUserData: 自定義的數據,開發(fā)者可以傳入一些我們需要的數據供回調函數使用.注意:一般情況下我們需要將當前的OC類實例傳入,因為回調函數是純C語言,不能調用OC類中的屬性與方法,所以傳入OC實例以與本類中屬性方法交互.
  • inAQ: 調用回調函數的音頻隊列
  • inBuffer: 裝有音頻數據的audio queue buffer.
  • inStartTime: 當前音頻數據的時間戳.主要用于同步.
  • inNumberPacketDescriptions: 數據包描述參數.如果你正在錄制VBR格式,音頻隊列會提供此參數的值.如果錄制文件需要將其傳遞給AudioFileWritePackets函數.CBR格式不使用此參數.
  • inPacketDescs: 音頻數據中一組packet描述.如果是VBR格式數據,如果錄制文件需要將此值傳遞給AudioFileWritePackets函數
1.6.2. 播放的回調函數
AudioQueueOutputCallback (
    void                  *inUserData,
    AudioQueueRef         inAQ,
    AudioQueueBufferRef   inBuffer
);

在回調函數中將讀取音頻數據以用來播放

  • inUserData:自定義的數據,開發(fā)者可以傳入一些我們需要的數據供回調函數使用.注意:一般情況下我們需要將當前的OC類實例傳入,因為回調函數是純C語言,不能調用OC類中的屬性與方法,所以傳入OC實例以與本類中屬性方法交互.
  • inAQ:調用回調函數的音頻隊列
  • inBuffer:回調將要填充的數據舅柜。

如果應用程序正在播放VBR格式數據,這個回調函數需要通過AudioFileReadPackets獲取音頻數據包信息.然后,回調將數據包信息放入自定義數據結構中躲惰,以使其可用于播放音頻隊列致份。

1.7. 使用編解碼器

Audio Queue Services使音頻編解碼器用于轉換音頻數據格式.你的錄制或播放可以使用編解碼器支持的任意格式.

每個audio queue有一個自己的音頻數據格式,被封裝在AudioStreamBasicDescription中,通過mFormatID可以指定音頻數據格式,audio queue會自動選擇適當編解碼器對其壓縮.開發(fā)者可以指定采樣率,聲道數等等參數自定義音頻數據.

5.record_convert

如上圖,應用程序告訴音頻隊列使用指定格式開始錄制,音頻隊列在獲取到原生的PCM數據后使用編碼器將其轉換為AAC類型數據,然后音頻隊列通知回調函數,將轉換好的數據放入audio queue buffer中傳給回調函數.最后,回調函數拿到轉換好的AAC數據進行使用.

6.play_convert

如上圖,應用程序告訴音頻隊列播放指定的格式(AAC)的文件,音頻隊列調用回調函數從音頻文件中讀取音頻數據,回調函數將原始格式的數據傳給音頻隊列.最后,音頻隊列使用合適的解碼器將音頻數據(PCM)交給揚聲器.

音頻隊列可以利用任何編解碼器無論是系統(tǒng)自帶的還是第三方安裝的(僅Mac OS)

1.7. 生命周期

音頻隊列在創(chuàng)建與銷毀間的活動范圍稱為它的聲明周期.

  • Start (AudioQueueStart): 初始化
  • Prime (AudioQueuePrime): 僅用于播放,在調用AudioQueueStart前調用它確保當有可用的音頻數據時能夠立即播放.
  • Stop (AudioQueueStop): 重置音頻隊列,停止播放與錄制.
  • Pause (AudioQueuePause): 暫停錄制,播放不會影響音頻隊列中已有的數據.調用AudioQueueStart恢復.
  • Flush (AudioQueueFlush): 在音頻隊列最后一個buffer入隊時調用,確保所有的音頻數據處理完畢.
  • Reset (AudioQueueReset): 調用后會立即靜音,音頻隊列移除所有數據并且重置編解碼器與DSP狀態(tài).

AudioQueueStop可以選擇以同步或異步的方式停止.

  • Synchronous: 立即停止,忽略隊列中的數據
  • Asynchronous: 當隊列中所有數據被取出用完后再停止.

1.8. 參數設置

音頻隊列有一個可以調節(jié)的設置稱為參數,每個參數都有一個枚舉常量作為其鍵,一個浮點型作為其值,該值僅用于播放.

以下有兩種方式設置參數

  • 對于每個audio queue, 使用AudioQueueSetParameter:立即改變
  • 對于每個audio queue buffer,使用AudioQueueEnqueueBufferWithParameters,在入隊時進行設置,播放時,此類更改將生效础拨。

使用kAudioQueueParam_Volume可以調節(jié)播放音量(0.0~1.0)

2. 錄制

使用Audio Queue Services進行錄制,輸出端可以是一個文件,網絡協(xié)議傳輸,拷貝給一個對象等等.這里僅介紹輸出到文件.

流程

  • 自定義一個結構體去管理音頻格式,狀態(tài),文件路徑等等...
  • 使用audio queue做錄制
  • 選擇需要的每個音頻數據的大小,如果需要還可以生成magic cookies(元數據信息).
  • 設置自定義音頻數據格式,指定文件路徑.
  • 創(chuàng)建audio queue,分配audio queue buffer內存,執(zhí)行入隊操作.
  • 告訴audio queue開始錄制
  • 完成時停止audio queue并且回收audio queue buffer的內存.

2.1. 使用自定義結構體管理狀態(tài)信息

第一步是自定義一個結構體管理音頻格式及狀態(tài)信息.

static const int kNumberBuffers = 3;                            // 1
struct AQRecorderState {
    AudioStreamBasicDescription  mDataFormat;                   // 2
    AudioQueueRef                mQueue;                        // 3
    AudioQueueBufferRef          mBuffers[kNumberBuffers];      // 4
    AudioFileID                  mAudioFile;                    // 5
    UInt32                       bufferByteSize;                // 6
    SInt64                       mCurrentPacket;                // 7
    bool                         mIsRunning;                    // 8
};
  • kNumberBuffers: 使用多少個音頻隊列數據.
  • mDataFormat: 指定音頻數據格式
  • mQueue: 應用程序創(chuàng)建的錄制音頻隊列.
  • mBuffers: 音頻隊列中音頻數據指針的數組
  • mAudioFile: 錄制的文件
  • bufferByteSize: 當前錄制的文件的大小(單位是bytes)
  • mCurrentPacket: 要寫入當前錄制文件的音頻數據包的索引
  • mIsRunning: 當前音頻隊列是否正在運行.

2.2. 回調函數

static void HandleInputBuffer (
    void                                *aqData,             // 1
    AudioQueueRef                       inAQ,                // 2
    AudioQueueBufferRef                 inBuffer,            // 3
    const AudioTimeStamp                *inStartTime,        // 4
    UInt32                              inNumPackets,        // 5
    const AudioStreamPacketDescription  *inPacketDesc        // 6
)
  • aqData: 自定義的數據,開發(fā)者可以傳入一些我們需要的數據供回調函數使用.注意:一般情況下我們需要將當前的OC類實例傳入,因為回調函數是純C語言,不能調用OC類中的屬性與方法,所以傳入OC實例以與本類中屬性方法交互.
  • inAQ: 調用回調函數的音頻隊列
  • inBuffer: 裝有音頻數據的audio queue buffer.
  • inStartTime: 當前音頻數據的時間戳.主要用于同步.
  • inNumberPacketDescriptions: 數據包描述參數.如果你正在錄制VBR格式,音頻隊列會提供此參數的值.如果錄制文件需要將其傳遞給AudioFileWritePackets函數.CBR格式不使用此參數(值為0).
  • inPacketDescs: 音頻數據中一組packet描述.如果是VBR格式數據,如果錄制文件需要將此值傳遞給AudioFileWritePackets函數

2.3. 將數據寫入本地文件

使用AudioFileWritePackets將數據寫入音頻文件.

AudioFileWritePackets (                     // 1
    pAqData->mAudioFile,                    // 2
    false,                                  // 3
    inBuffer->mAudioDataByteSize,           // 4
    inPacketDesc,                           // 5
    pAqData->mCurrentPacket,                // 6
    &inNumPackets,                          // 7
    inBuffer->mAudioData                    // 8
);

  • 1.將音頻數據寫入音頻文件
  • 2.要寫入的音頻文件
  • 3.使用false表示寫入文件時不應緩存數據
  • 4.被寫入文件的大小
  • 5.一組音頻數據包的描述,如2.2中介紹,如果是CBR設置為NULL,如果是VBR需要設置回調函數中的inPacketDesc參數.
  • 6.當前寫入的數據包的索引
  • 7.輸入(錄制)時氮块,要寫入的數據包數。輸出(播放)時诡宗,實際寫入的數據包數
  • 8.要寫入的音頻數據.

2.4. 入隊

當音頻數據在回調函數中用完后,需要重新放回音頻隊列以便存儲新的音頻數據

AudioQueueEnqueueBuffer (                    // 1
    pAqData->mQueue,                         // 2
    inBuffer,                                // 3
    0,                                       // 4
    NULL                                     // 5
);
  • 1.將音頻數據放入音頻隊列
  • 2.錄制的音頻隊列
  • 3.等待入隊的音頻數據
  • 4.音頻數據包的描述信息,設置為0因為該參數不用于錄制.
  • 5.描述音頻隊列數據的數據包描述數組滔蝉。設置為NULL因為該參數不用于錄制.

2.5. 完整的錄制回調

static void HandleInputBuffer (
    void                                 *aqData,
    AudioQueueRef                        inAQ,
    AudioQueueBufferRef                  inBuffer,
    const AudioTimeStamp                 *inStartTime,
    UInt32                               inNumPackets,
    const AudioStreamPacketDescription   *inPacketDesc
) {
    AQRecorderState *pAqData = (AQRecorderState *) aqData;               // 1
 
    if (inNumPackets == 0 &&                                             // 2
          pAqData->mDataFormat.mBytesPerPacket != 0)
       inNumPackets =
           inBuffer->mAudioDataByteSize / pAqData->mDataFormat.mBytesPerPacket;
 
    if (AudioFileWritePackets (                                          // 3
            pAqData->mAudioFile,
            false,
            inBuffer->mAudioDataByteSize,
            inPacketDesc,
            pAqData->mCurrentPacket,
            &inNumPackets,
            inBuffer->mAudioData
        ) == noErr) {
            pAqData->mCurrentPacket += inNumPackets;                     // 4
    }
   if (pAqData->mIsRunning == 0)                                         // 5
      return;
 
    AudioQueueEnqueueBuffer (                                            // 6
        pAqData->mQueue,
        inBuffer,
        0,
        NULL
    );
}
  • 1.用于記錄音頻隊列一些信息的結構體,里面包含當前錄制文件的信息,狀態(tài)等等參數.
  • 2.如果音頻數據是CBR數據,計算當前數據中包含多少個音頻數據包.對于VBR數據,可以直接從回調函數中的inNumPackets參數獲取.
  • 3.將音頻數據寫入音頻文件
  • 4.如果成功的話,需要將音頻數據包索引累加,以便下次可以繼續(xù)錄制
  • 5.如果audio queue已經停止則返回.
  • 6.使用完的音頻隊列數據重新裝入音頻隊列.

2.6. 獲取Audio Queue Buffer大小

void DeriveBufferSize (
    AudioQueueRef                audioQueue,                  // 1
    AudioStreamBasicDescription  &ASBDescription,             // 2
    Float64                      seconds,                     // 3
    UInt32                       *outBufferSize               // 4
) {
    static const int maxBufferSize = 0x50000;                 // 5
 
    int maxPacketSize = ASBDescription.mBytesPerPacket;       // 6
    if (maxPacketSize == 0) {                                 // 7
        UInt32 maxVBRPacketSize = sizeof(maxPacketSize);
        AudioQueueGetProperty (
                audioQueue,
                kAudioQueueProperty_MaximumOutputPacketSize,
                // in Mac OS X v10.5, instead use
                //   kAudioConverterPropertyMaximumOutputPacketSize
                &maxPacketSize,
                &maxVBRPacketSize
        );
    }
 
    Float64 numBytesForTime =
        ASBDescription.mSampleRate * maxPacketSize * seconds; // 8
    *outBufferSize =
    UInt32 (numBytesForTime < maxBufferSize ?
        numBytesForTime : maxBufferSize);                     // 9
}
  • 1.指定的音頻隊列
  • 2.音頻隊列配置信息
  • 3.音頻數據采集的間隔(可以通過采樣率與間隔算出每個采集數據的大小)
  • 4.通過該參數返回計算出的音頻數據的大小
  • 5.音頻隊列數據大小的上限,以字節(jié)為單位塔沃。在此示例中蝠引,上限設置為320 KB。這相當于采樣速率為96 kHz的大約5秒的立體聲蛀柴,24位音頻螃概。
  • 6.對于CBR的數據,可以從ASBD中獲取該值大小.如果是VBR數據,ASBD中取出得值為0.
  • 7.對于VBR數據,需要手動估算一個最大值.
  • 8.獲取音頻數據大小(字節(jié))
  • 9.如果需要,限制音頻數據最大值.

2.7. 為音頻文件設置magin cookie

對于一些壓縮音頻數據格式,如AAC,MPEG 4 AAC等,必須包含音頻元數據.包含該元數據信息的數據結構稱為magic cookies.當你錄制壓縮音頻數據格式的音頻文件時,必須從audio queue中獲取元數據并將其設置給音頻文件.

注意: 我們在錄制前與停止錄制后兩個時間點都設置一次magin cookie,因為有的編碼器需要在停止錄制后更新magin cookie.

OSStatus SetMagicCookieForFile (
    AudioQueueRef inQueue,                                      // 1
    AudioFileID   inFile                                        // 2
) {
    OSStatus result = noErr;                                    // 3
    UInt32 cookieSize;                                          // 4
 
    if (
            AudioQueueGetPropertySize (                         // 5
                inQueue,
                kAudioQueueProperty_MagicCookie,
                &cookieSize
            ) == noErr
    ) {
        char* magicCookie =
            (char *) malloc (cookieSize);                       // 6
        if (
                AudioQueueGetProperty (                         // 7
                    inQueue,
                    kAudioQueueProperty_MagicCookie,
                    magicCookie,
                    &cookieSize
                ) == noErr
        )
            result =    AudioFileSetProperty (                  // 8
                            inFile,
                            kAudioFilePropertyMagicCookieData,
                            cookieSize,
                            magicCookie
                        );
        free (magicCookie);                                     // 9
    }
    return result;                                              // 10
}
  • 1.錄制的音頻隊列
  • 2.準備錄制的文件
  • 3.定義一個變量記錄設置是否成功
  • 4.定義一個變量記錄magic cookie的大小
  • 5.從audio queue中獲取magic cookie的大小.
  • 6.定義一個變量記錄magic cookie的內容并為其分配需要的內存
  • 7.從audio queue中獲取magic cookie的內容
  • 8.將獲取到的magic cookie設置到文件中.
  • 9.釋放剛才臨時保存的magic cookie變量
  • 10.返回設置的結果

2.8.設置錄制音頻的格式.

主要關注以下參數

  • 音頻格式(PCM,AAC...)
  • 采樣率(44.1kHz, 48kHz)
  • 聲道數(單聲道,雙聲道)
  • 采樣位數(16bits)
  • 每個音頻數據包中的幀數(線性PCM通常是1幀,壓縮數據通常比較多)
  • 音頻文件類型(CAF, AIFF...)
AQRecorderState aqData;                                       // 1
 
aqData.mDataFormat.mFormatID         = kAudioFormatLinearPCM; // 2
aqData.mDataFormat.mSampleRate       = 44100.0;               // 3
aqData.mDataFormat.mChannelsPerFrame = 2;                     // 4
aqData.mDataFormat.mBitsPerChannel   = 16;                    // 5
aqData.mDataFormat.mBytesPerPacket   =                        // 6
   aqData.mDataFormat.mBytesPerFrame =
      aqData.mDataFormat.mChannelsPerFrame * sizeof (SInt16);
aqData.mDataFormat.mFramesPerPacket  = 1;                     // 7
 
AudioFileTypeID fileType             = kAudioFileAIFFType;    // 8
aqData.mDataFormat.mFormatFlags =                             // 9
    kLinearPCMFormatFlagIsBigEndian
    | kLinearPCMFormatFlagIsSignedInteger
    | kLinearPCMFormatFlagIsPacked;

  • 1.創(chuàng)建一個存放音頻狀態(tài)信息的結構體.(結構體名字自定義)
  • 2.指定音頻格式
  • 3.指定采樣率
  • 4.指定聲道數
  • 5.指定采樣位數
  • 6.指定每個包中的字節(jié)數
  • 7.指定每個包中的幀數
  • 8.指定文件類型
  • 9.指定文件類型所需要的標志

2.9. 創(chuàng)建錄制的Audio Queue

AudioQueueNewInput (                              // 1
    &aqData.mDataFormat,                          // 2
    HandleInputBuffer,                            // 3
    &aqData,                                      // 4
    NULL,                                         // 5
    kCFRunLoopCommonModes,                        // 6
    0,                                            // 7
    &aqData.mQueue                                // 8
);
  • 1.創(chuàng)建一個錄制音頻隊列
  • 2.指定錄制的音頻格式
  • 3.指定回調函數
  • 4.可傳入自定義的數據結構,可以是本類的實例,可以是記錄音頻信息的結構體
  • 5.回調函數在哪個循環(huán)中被調用.設置為NULL為默認值,即回調函數所在的線程由audio queue內部控制.
  • 6.回調函數運行循環(huán)模式通常使用kCFRunLoopCommonModes.
  • 7.保留值,只能為0.
  • 8.輸出時新分配的音頻隊列.

2.10. 獲取完整的音頻格式.

當audio queue開始工作后,它可能會產生更多音頻格式信息比我們初始化設置時,所以我們需要對獲取到的音頻數據做一個檢查.

UInt32 dataFormatSize = sizeof (aqData.mDataFormat);       // 1
 
AudioQueueGetProperty (                                    // 2
    aqData.mQueue,                                         // 3
    kAudioQueueProperty_StreamDescription,                 // 4
    // in Mac OS X, instead use
    //    kAudioConverterCurrentInputStreamDescription
    &aqData.mDataFormat,                                   // 5
    &dataFormatSize                                        // 6
);
  • 1.查詢音頻數據格式
  • 2.獲取audio queue指定屬性的值
  • 3.查詢的音頻隊列
  • 4.音頻隊列數據格式的ID
  • 5.作為輸出,輸出完整的音頻數據格式
  • 6.在輸入時,AudioStreamBasicDescription結構的預期大小名扛。在輸出時谅年,實際大小。您的錄制應用程序不需要使用此值肮韧。

2.11. 創(chuàng)建一個音頻文件

CFURLRef audioFileURL =
    CFURLCreateFromFileSystemRepresentation (            // 1
        NULL,                                            // 2
        (const UInt8 *) filePath,                        // 3
        strlen (filePath),                               // 4
        false                                            // 5
    );
 
AudioFileCreateWithURL (                                 // 6
    audioFileURL,                                        // 7
    fileType,                                            // 8
    &aqData.mDataFormat,                                 // 9
    kAudioFileFlags_EraseFile,                           // 10
    &aqData.mAudioFile                                   // 11
);
  • 1.創(chuàng)建一個CFURL類型的對象代表錄制文件路徑
  • 2.使用NULL(kCFAllocatorDefault)使用當前默認的內存分配器
  • 3.設置文件路徑
  • 4.文件名長度
  • 5.false表示是一個文件,不是文件夾.
  • 6.創(chuàng)建一個新的文件或初始化一個已經存在的文件.
  • 7.音頻文件的路徑(即3中創(chuàng)建的)
  • 8.音頻文件類型.(CAF,AIFF...)
  • 9.ASBD
  • 10.設置該值表示如果文件已經存在則覆蓋
  • 11.代表錄制的文件.

2.12. 設置音頻隊列數據大小

使用2.6.章節(jié)中的函數設置音頻隊列數據的大小以便后續(xù)使用.

DeriveBufferSize (                               // 1
    aqData.mQueue,                               // 2
    aqData.mDataFormat,                          // 3
    0.5,                                         // 4
    &aqData.bufferByteSize                       // 5
);

2.13. 為Audio Queue準備指定數量的buffer

for (int i = 0; i < kNumberBuffers; ++i) {           // 1
    AudioQueueAllocateBuffer (                       // 2
        aqData.mQueue,                               // 3
        aqData.bufferByteSize,                       // 4
        &aqData.mBuffers[i]                          // 5
    );
 
    AudioQueueEnqueueBuffer (                        // 6
        aqData.mQueue,                               // 7
        aqData.mBuffers[i],                          // 8
        0,                                           // 9
        NULL                                         // 10
    );
}
  • 1.一般指定3個,這里為一個簡單的循環(huán),為指定數量的buffer分配內存并進行入隊操作
  • 2.為每個buffer分配內存
  • 3.指定分配內存的音頻隊列
  • 4.指定分配內存的Buffer的大小(即2.12中獲取的)
  • 5.輸出一個分配好內存的buffer
  • 6.音頻隊列入隊
  • 7.將要入隊的音頻隊列
  • 8.將要入隊的音頻數據
  • 9.對于錄制此參數沒用
  • 10.對于錄制此參數沒用

2.14. 錄制音頻

aqData.mCurrentPacket = 0;                           // 1
aqData.mIsRunning = true;                            // 2
 
AudioQueueStart (                                    // 3
    aqData.mQueue,                                   // 4
    NULL                                             // 5
);
// Wait, on user interface thread, until user stops the recording
AudioQueueStop (                                     // 6
    aqData.mQueue,                                   // 7
    true                                             // 8
);
 
aqData.mIsRunning = false;                           // 9
  • 初始化記錄當前錄制文件packet索引為0
  • 表明audio queue正在運行
  • 開啟一個audio queue
  • 指定開啟的audio queue
  • 設置為NULL表示立即開始采集數據
  • 停止并重置當前音頻隊列
  • 指定停止的音頻隊列
  • true:同步停止, false: 異步停止
  • 更新音頻隊列當前工作狀態(tài).

2.15. 錄制完成清理內存

錄制完成后,回收音頻隊列數據,關閉音頻文件.

AudioQueueDispose (                                 // 1
    aqData.mQueue,                                  // 2
    true                                            // 3
);
 
AudioFileClose (aqData.mAudioFile);                 // 4
  • 1.回收音頻隊列中所有資源
  • 2.指定回收的音頻隊列
  • 3.true: 同步, false:異步
  • 4.關閉錄制文件.

3. 播放

使用 Audio Queue Services播放音頻時,源數據可以是本地文件, 內存中的對象或者其他音頻存儲方式.本章中僅介紹通過本地文件播放.

  • 定義一個結構體管理音頻格式狀態(tài)信息等.
  • 實現(xiàn)一個播放回調函數
  • 設置音頻隊列數據大小
  • 打開一個音頻文件,確定音頻數據格式
  • 創(chuàng)建并配置一個播放的音頻隊列
  • 為音頻隊列數據分配內存并入隊.告訴音頻隊列開始播放.完成時,告訴音頻隊列停止.
  • 回收內存,釋放資源

3.1. 定義一個結構體管理音頻狀態(tài)

static const int kNumberBuffers = 3;                              // 1
struct AQPlayerState {
    AudioStreamBasicDescription   mDataFormat;                    // 2
    AudioQueueRef                 mQueue;                         // 3
    AudioQueueBufferRef           mBuffers[kNumberBuffers];       // 4
    AudioFileID                   mAudioFile;                     // 5
    UInt32                        bufferByteSize;                 // 6
    SInt64                        mCurrentPacket;                 // 7
    UInt32                        mNumPacketsToRead;              // 8
    AudioStreamPacketDescription  *mPacketDescs;                  // 9
    bool                          mIsRunning;                     // 10
};

此結構體中的數據基本與錄制時相同.

  • 1.設置音頻隊列中可復用的音頻數據個數,通常為3
  • 2.ASBD
  • 3.播放使用的音頻隊列
  • 4.管理音頻隊列中音頻數據的數組
  • 5.播放用的音頻文件
  • 6.每個音頻數據的大小
  • 7.當前準備播放的音頻數據包索引
  • 8.每次調用回調函數要讀取的音頻數據包的個數
  • 9.對于VBR音頻數據,表示正在播放的音頻數據包描述性數組,對于CBR音頻數據可以設為NULL.
  • 10.音頻隊列是否正在運行.

3.2.回調函數

作用

  • 從音頻文件中讀取指定數量的音頻數據并將其裝入音頻隊列數據.
  • 將音頻隊列數據入隊
  • 文件讀取完成后,停止音頻隊列
3.2.1. 定義回調函數
static void HandleOutputBuffer (
    void                 *aqData,                 // 1
    AudioQueueRef        inAQ,                    // 2
    AudioQueueBufferRef  inBuffer                 // 3
)

  • 1.同錄制,自定義的結構體或類對象,可傳入回調函數中使用,即OC類與回調函數間的通信對象
  • 2.當前工作的音頻隊列
  • 3.通過讀取音頻文件獲取的音頻數據
3.2.2. 讀取音頻文件
AudioFileReadPackets (                        // 1
    pAqData->mAudioFile,                      // 2
    false,                                    // 3
    &numBytesReadFromFile,                    // 4
    pAqData->mPacketDescs,                    // 5
    pAqData->mCurrentPacket,                  // 6
    &numPackets,                              // 7
    inBuffer->mAudioData                      // 8
);
  • 1.讀取文件的函數
  • 2.要讀取的音頻文件
  • 3.false:讀取時不應緩存數據.
  • 4.作為輸出:將從文件讀取的字節(jié)數
  • 5.作為輸出:VBR:從音頻文件讀取到的數據包描述數組,CBR:NULL
  • 6.當前讀取到的索引值,以便下次繼續(xù)讀取
  • 7.作輸入時:從音頻文件中讀取到的音頻數據包數,作輸出時:實際讀取到的音頻數據包
  • 8.作輸出時:從音頻文件中讀取的數據
3.2.3. 入隊

讀取完音頻數據后,執(zhí)行入隊操作.

AudioQueueEnqueueBuffer (                      // 1
    pAqData->mQueue,                           // 2
    inBuffer,                                  // 3
    (pAqData->mPacketDescs ? numPackets : 0),  // 4
    pAqData->mPacketDescs                      // 5
);

  • 4.音頻數據包數,CBR的數據使用0
  • 5.對于壓縮數據使用其數據包描述信息
3.2.4. 停止音頻隊列

如果檢查到當前音頻文件讀取完畢,應該停止音頻隊列.

if (numPackets == 0) {                          // 1
    AudioQueueStop (                            // 2
        pAqData->mQueue,                        // 3
        false                                   // 4
    );
    pAqData->mIsRunning = false;                // 5
}
  • 1.通過AudioFileReadPackets檢查數據包是否為0
  • 4.true:同步, false:異步
3.2.5. 完整的回調
static void HandleOutputBuffer (
    void                *aqData,
    AudioQueueRef       inAQ,
    AudioQueueBufferRef inBuffer
) {
    AQPlayerState *pAqData = (AQPlayerState *) aqData;        // 1
    if (pAqData->mIsRunning == 0) return;                     // 2
    UInt32 numBytesReadFromFile;                              // 3
    UInt32 numPackets = pAqData->mNumPacketsToRead;           // 4
    AudioFileReadPackets (
        pAqData->mAudioFile,
        false,
        &numBytesReadFromFile,
        pAqData->mPacketDescs, 
        pAqData->mCurrentPacket,
        &numPackets,
        inBuffer->mAudioData 
    );
    if (numPackets > 0) {                                     // 5
        inBuffer->mAudioDataByteSize = numBytesReadFromFile;  // 6
       AudioQueueEnqueueBuffer ( 
            pAqData->mQueue,
            inBuffer,
            (pAqData->mPacketDescs ? numPackets : 0),
            pAqData->mPacketDescs
        );
        pAqData->mCurrentPacket += numPackets;                // 7 
    } else {
        AudioQueueStop (
            pAqData->mQueue,
            false
        );
        pAqData->mIsRunning = false; 
    }
}
  • 3.記錄讀取到的字節(jié)數
  • 4.記錄讀取到音頻數據包數
  • 7.累加音頻數據包,使下次觸發(fā)回調可以接著上次內容繼續(xù)播放

3.3. 計算音頻隊列數據

我們需要指定一個音頻隊列buffer的大小.根據計算出來的大小為音頻隊列數據分配內存.

  • 回調函數中調用AudioFileReadPackets獲取讀取到的包數
  • 設置音頻buffer下限值,避免訪問過于頻繁.
void DeriveBufferSize (
    AudioStreamBasicDescription &ASBDesc,                            // 1
    UInt32                      maxPacketSize,                       // 2
    Float64                     seconds,                             // 3
    UInt32                      *outBufferSize,                      // 4
    UInt32                      *outNumPacketsToRead                 // 5
) {
    static const int maxBufferSize = 0x50000;                        // 6
    static const int minBufferSize = 0x4000;                         // 7
 
    if (ASBDesc.mFramesPerPacket != 0) {                             // 8
        Float64 numPacketsForTime =
            ASBDesc.mSampleRate / ASBDesc.mFramesPerPacket * seconds;
        *outBufferSize = numPacketsForTime * maxPacketSize;
    } else {                                                         // 9
        *outBufferSize =
            maxBufferSize > maxPacketSize ?
                maxBufferSize : maxPacketSize;
    }
 
    if (                                                             // 10
        *outBufferSize > maxBufferSize &&
        *outBufferSize > maxPacketSize
    )
        *outBufferSize = maxBufferSize;
    else {                                                           // 11
        if (*outBufferSize < minBufferSize)
            *outBufferSize = minBufferSize;
    }
 
    *outNumPacketsToRead = *outBufferSize / maxPacketSize;           // 12
}
  • 2.估算當前播放音頻文件最大數據包大小,通過調用AudioFileGetProperty查詢kAudioFilePropertyPacketSizeUpperBound屬性可得
  • 3.采樣時間,根據采樣率與采樣時間可計算出音頻數據大小
  • 4.每個音頻數據的大小
  • 5.每次從音頻播放回調中讀取的音頻數據包數
  • 6.音頻數據包大小的上限
  • 7.音頻數據包大小的下限
  • 8.計算音頻數據包總大小
  • 9.根據最大數據包大小和您設置的上限導出合理的音頻隊列數據大小
  • 10.設置上限
  • 11.設置下限
  • 12.計算讀取到的音頻數據包數

3.4. 打開音頻文件

  • 獲取一個CFURL對象表示音頻文件路徑
  • 打開音頻文件
  • 獲取文件格式
3.4.1. 獲取一個CFURL對象表示音頻文件路徑
CFURLRef audioFileURL =
    CFURLCreateFromFileSystemRepresentation (           // 1
        NULL,                                           // 2
        (const UInt8 *) filePath,                       // 3
        strlen (filePath),                              // 4
        false                                           // 5
    );
  • 1.創(chuàng)建一個CFURL類型的對象代表錄制文件路徑
  • 2.使用NULL(kCFAllocatorDefault)使用當前默認的內存分配器
  • 3.設置文件路徑
  • 4.文件名長度
  • 5.false表示是一個文件,不是文件夾.
3.4.2. 打開音頻文件
AQPlayerState aqData;                                   // 1
 
OSStatus result =
    AudioFileOpenURL (                                  // 2
        audioFileURL,                                   // 3
        fsRdPerm,                                       // 4
        0,                                              // 5
        &aqData.mAudioFile                              // 6
    );
 
CFRelease (audioFileURL);                               // 7
  • 2.打開一個想要播放的音頻文件
  • 3.音頻文件路徑
  • 4.文件權限
  • 5.可選文件類型,0:不使用此參數
  • 6.作為輸出,獲取文件對象的引用
3.4.3. 獲取文件格式
UInt32 dataFormatSize = sizeof (aqData.mDataFormat);    // 1
 
AudioFileGetProperty (                                  // 2
    aqData.mAudioFile,                                  // 3
    kAudioFilePropertyDataFormat,                       // 4
    &dataFormatSize,                                    // 5
    &aqData.mDataFormat                                 // 6
);
  • 5.作為輸入:輸入時融蹂,AudioStreamBasicDescription結構體的預期大小,用于描述音頻文件的數據格式弄企。在輸出時超燃,實際大小。作播放時不需要使用此值拘领。
  • 6.輸出:將文件代表的ASBD數據格式賦給該變量

3.5. 創(chuàng)建播放音頻隊列

AudioQueueNewOutput (                                // 1
    &aqData.mDataFormat,                             // 2
    HandleOutputBuffer,                              // 3
    &aqData,                                         // 4
    CFRunLoopGetCurrent (),                          // 5
    kCFRunLoopCommonModes,                           // 6
    0,                                               // 7
    &aqData.mQueue                                   // 8
);
  • 3.回調函數
  • 4.音頻隊列數據
  • 5.調用播放回調的的運行循環(huán)
  • 6.調用播放回調運行循環(huán)的模式

3.6. 設置播放音頻隊列大小

3.6.1. 設置buffer size與讀取的音頻數據包數量
UInt32 maxPacketSize;
UInt32 propertySize = sizeof (maxPacketSize);
AudioFileGetProperty (                               // 1
    aqData.mAudioFile,                               // 2
    kAudioFilePropertyPacketSizeUpperBound,          // 3
    &propertySize,                                   // 4
    &maxPacketSize                                   // 5
);
 
DeriveBufferSize (                                   // 6
    aqData.mDataFormat,                              // 7
    maxPacketSize,                                   // 8
    0.5,                                             // 9
    &aqData.bufferByteSize,                          // 10
    &aqData.mNumPacketsToRead                        // 11
);
3.6.2. 為數據包描述數組分配內存
bool isFormatVBR = (                                       // 1
    aqData.mDataFormat.mBytesPerPacket == 0 ||
    aqData.mDataFormat.mFramesPerPacket == 0
);
 
if (isFormatVBR) {                                         // 2
    aqData.mPacketDescs =
      (AudioStreamPacketDescription*) malloc (
        aqData.mNumPacketsToRead * sizeof (AudioStreamPacketDescription)
      );
} else {                                                   // 3
    aqData.mPacketDescs = NULL;
}
  • 1.判斷音頻文件數據是VBR還是CBR.對于VBR數據,每個數據包中的幀數(同理每個數據包中的字節(jié)數也是一樣)是可變的,所以此屬性為0.
  • 2.對于VBR數據,為數據包描述字典分配指定內存.
  • 3.對于CBR數據,不需要使用該參數,直接設為NULL

3.7. 設置magic cookie

對于壓縮的音頻數據格式(AAC...),我們在播放前必須為音頻隊列設置magic cookies,即元數據信息.

UInt32 cookieSize = sizeof (UInt32);                   // 1
bool couldNotGetProperty =                             // 2
    AudioFileGetPropertyInfo (                         // 3
        aqData.mAudioFile,                             // 4
        kAudioFilePropertyMagicCookieData,             // 5
        &cookieSize,                                   // 6
        NULL                                           // 7
    );
 
if (!couldNotGetProperty && cookieSize) {              // 8
    char* magicCookie =
        (char *) malloc (cookieSize);
 
    AudioFileGetProperty (                             // 9
        aqData.mAudioFile,                             // 10
        kAudioFilePropertyMagicCookieData,             // 11
        &cookieSize,                                   // 12
        magicCookie                                    // 13
    );
 
    AudioQueueSetProperty (                            // 14
        aqData.mQueue,                                 // 15
        kAudioQueueProperty_MagicCookie,               // 16
        magicCookie,                                   // 17
        cookieSize                                     // 18
    );
 
    free (magicCookie);                                // 19
}
  • 1.根據UInt32估算magic cookie數據大小
  • 2.記錄是否能獲取magic cookie結果
  • 3.獲取文件中的magic cookie的大小意乓。
  • 4.想要播放的文件
  • 5.key值,代表音頻文件的kAudioFilePropertyMagicCookieData
  • 6.作輸入時表示magic cookie估算大小,輸出時表示實際大小
  • 7.設置為NULL表示不關心此屬性的讀寫權限
  • 8.如果文件包含magic cookie,分配內存去持有它
  • 9.獲取文件中的magic cookie
  • 12.輸入時表示文件中的magic cookie的大小
  • 13.輸出為文件的magic cookie
  • 14.設置audio queue的函數

3.8. 分配音頻隊列數據

aqData.mCurrentPacket = 0;                                // 1
 
for (int i = 0; i < kNumberBuffers; ++i) {                // 2
    AudioQueueAllocateBuffer (                            // 3
        aqData.mQueue,                                    // 4
        aqData.bufferByteSize,                            // 5
        &aqData.mBuffers[i]                               // 6
    );
 
    HandleOutputBuffer (                                  // 7
        &aqData,                                          // 8
        aqData.mQueue,                                    // 9
        aqData.mBuffers[i]                                // 10
    );
}
  • 1.初始化讀取音頻數據包索引為0
  • 7.自定義的播放音頻回調函

3.9. 設置音量

開始播放前,可以設置音量(0~1)

Float32 gain = 1.0;                                       // 1
    // Optionally, allow user to override gain setting here
AudioQueueSetParameter (                                  // 2
    aqData.mQueue,                                        // 3
    kAudioQueueParam_Volume,                              // 4
    gain                                                  // 5
);

3.10. 啟動Audio Queue

aqData.mIsRunning = true;                          // 1
 
AudioQueueStart (                                  // 2
    aqData.mQueue,                                 // 3
    NULL                                           // 4
);
 
do {                                               // 5
    CFRunLoopRunInMode (                           // 6
        kCFRunLoopDefaultMode,                     // 7
        0.25,                                      // 8
        false                                      // 9
    );
} while (aqData.mIsRunning);
 
CFRunLoopRunInMode (                               // 10
    kCFRunLoopDefaultMode,
    1,
    false
);

  • 4.設置為NULL表示馬上開始播放
  • 8.設置運行循環(huán)的時間是0.25秒
  • 9.使用false表示運行循環(huán)應該在指定的完整時間內繼續(xù)
  • 10.音頻隊列停止后,運行循環(huán)運行一段時間以確保當前播放的音頻隊列緩沖區(qū)有時間完成约素。

3.11. 清理

播放完成后應該回收音頻隊列,關閉音頻文件,釋放所有相關資源

AudioQueueDispose (                            // 1
    aqData.mQueue,                             // 2
    true                                       // 3
);
 
AudioFileClose (aqData.mAudioFile);            // 4
 
free (aqData.mPacketDescs);                    // 5
  • 3:true: 同步, false:異步

Apple官方文檔

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末届良,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子圣猎,更是在濱河造成了極大的恐慌士葫,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,039評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件送悔,死亡現(xiàn)場離奇詭異慢显,居然都是意外死亡爪模,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,426評論 3 395
  • 文/潘曉璐 我一進店門荚藻,熙熙樓的掌柜王于貴愁眉苦臉地迎上來屋灌,“玉大人,你說我怎么就攤上這事应狱」补” “怎么了?”我有些...
    開封第一講書人閱讀 165,417評論 0 356
  • 文/不壞的土叔 我叫張陵侦香,是天一觀的道長落塑。 經常有香客問我纽疟,道長罐韩,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,868評論 1 295
  • 正文 為了忘掉前任污朽,我火速辦了婚禮散吵,結果婚禮上,老公的妹妹穿的比我還像新娘蟆肆。我一直安慰自己矾睦,他們只是感情好,可當我...
    茶點故事閱讀 67,892評論 6 392
  • 文/花漫 我一把揭開白布炎功。 她就那樣靜靜地躺著枚冗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪蛇损。 梳的紋絲不亂的頭發(fā)上赁温,一...
    開封第一講書人閱讀 51,692評論 1 305
  • 那天,我揣著相機與錄音淤齐,去河邊找鬼股囊。 笑死,一個胖子當著我的面吹牛更啄,可吹牛的內容都是我干的稚疹。 我是一名探鬼主播,決...
    沈念sama閱讀 40,416評論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼祭务,長吁一口氣:“原來是場噩夢啊……” “哼内狗!你這毒婦竟也來了?” 一聲冷哼從身側響起义锥,我...
    開封第一講書人閱讀 39,326評論 0 276
  • 序言:老撾萬榮一對情侶失蹤柳沙,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后缨该,有當地人在樹林里發(fā)現(xiàn)了一具尸體偎行,經...
    沈念sama閱讀 45,782評論 1 316
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,957評論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了蛤袒。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片熄云。...
    茶點故事閱讀 40,102評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖妙真,靈堂內的尸體忽然破棺而出缴允,到底是詐尸還是另有隱情,我是刑警寧澤珍德,帶...
    沈念sama閱讀 35,790評論 5 346
  • 正文 年R本政府宣布练般,位于F島的核電站,受9級特大地震影響锈候,放射性物質發(fā)生泄漏薄料。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,442評論 3 331
  • 文/蒙蒙 一泵琳、第九天 我趴在偏房一處隱蔽的房頂上張望摄职。 院中可真熱鬧,春花似錦获列、人聲如沸谷市。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,996評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽迫悠。三九已至,卻和暖如春巩梢,著一層夾襖步出監(jiān)牢的瞬間创泄,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,113評論 1 272
  • 我被黑心中介騙來泰國打工且改, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留验烧,地道東北人。 一個月前我還...
    沈念sama閱讀 48,332評論 3 373
  • 正文 我出身青樓又跛,卻偏偏與公主長得像碍拆,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子慨蓝,可洞房花燭夜當晚...
    茶點故事閱讀 45,044評論 2 355

推薦閱讀更多精彩內容