音視頻開發(fā)之旅(67) - 變速不變調之sonic源碼分析

目錄

  1. 基音周期、濁音的概念
  2. Sonic源碼分析
  3. 資料
  4. 收獲

上一篇我們學習了音頻變速不變調的原理以及WSOLA波形相似疊加算法進行時域壓擴處理。其中在尋找相似幀方面,Sonic采用AMDF(平均幅度差函數(shù)法)方法來進行尋找。

一、基音周期、濁音的概念

圖片來自:[清音or濁音 ]

人體的發(fā)音器官可以分為三大部分:動力區(qū) 聲源區(qū) 調音區(qū)

1.動力區(qū)—— 肺 、橫膈膜、氣管

肺部呼出的氣流是語音的原動力。肺部呼出的氣流,通過支氣管到達喉頭,作用于聲帶、咽腔、口腔 、鼻腔等發(fā)音器官。

2.聲源區(qū)——喉頭、聲帶

用手摸脖子那里的喉頭,聲帶就位于喉頭的后面,

聲帶是兩片富有彈性的帶狀薄膜,兩片聲帶之間的空隙叫聲門。

從肺部呼出的氣流通過關閉著的聲門時,會引起聲帶振動而發(fā)出聲音

如果你把手貼在脖子上喉的部位,發(fā)聲時,手會感到輕微的震動,這是因為聲帶在振動。

嗓音的高低、粗細是由聲帶的松緊程度、呼出的氣體多少決定的。

3.調音區(qū)————口腔、鼻腔、咽腔

調音區(qū)主要是口腔,鼻腔,咽腔三大部分,其中口腔主要包括唇、齒和舌頭。(口腔后面是咽腔,咽頭上通口腔、鼻腔,下接喉頭。)

引用:[清音or濁音](https://zhuanlan.zhihu.com/p/374857199)

濁音的發(fā)音過程是:來自肺部的氣流沖擊聲門,造成聲門的一張一合,形成一系列準周期的氣流脈沖,經(jīng)過聲道(含口腔、鼻腔)的諧振及唇齒的輻射最終形成語音信號。故濁音波形呈現(xiàn)一定的準周期性。
所謂基音周期,就是對這種準周期而言的,它反映了聲門相鄰兩次開閉之間的時間間隔或開閉的頻率

基音周期是語音信號最重要的參數(shù)之一,但是基音的提取是比較困難的。
主要體現(xiàn)在

1. 聲門激勵信號并不是一個完全的周期序列
2. 基音頻率大多數(shù)情況是在100-200HZ,但是濁音信號往往啃根包含幾十個諧波分量,而其基波分量往往不是最強的,造成基音檢測時,把諧波當做了基波。
3. 基波周期的變化分為比較大,老年男性50 Hz,兒童和女性500 Hz。
引用:[語音識別 08 基音周期的估算方法](https://zhuanlan.zhihu.com/p/454283094)

基音檢測的方法主要有自相關函數(shù)法,平均幅度差函數(shù)法等。而Sonic的實現(xiàn)采用的就是平均幅度差函數(shù)法,這也是sonic 變速不變調最重要的一步。

二、Sonic源碼分析

sonic源碼地址:https://github.com/waywardgeek/sonic
可以看到它有兩份實現(xiàn)Java版本(Sonic.java)和Cpp版本(Sonic.cpp),并且代碼量都比較少,作者給出了性能對比,基本上也沒什么差別。
而android中大名鼎鼎的Exoplayer的變速不變調的實現(xiàn)就是基于Sonic.java,我們結合Exoplayer的實現(xiàn)來進行分析。

主要有兩個類SonicAudioProcessor和Sonic,其中SonicAudioProcessor是對Sonic做了一層封裝為了適配Exoplayer的框架。

public final class SonicAudioProcessor {
    private float speed;
    private float pitch;

    private Sonic sonic;
    private ByteBuffer buffer;
    private ShortBuffer shortBuffer;
    private ByteBuffer outputBuffer;

    public void setSpeed(float speed) {
        if (this.speed != speed) {
            this.speed = speed;
            ...
            flush();
        }
    }

   //速度發(fā)生變化后,重新初始化Sonic。
   private void flush() {
      ...
         sonic = new Sonic(
                    mSampleRate,//輸入采樣率
                    mChannelCount,//采樣通道數(shù)
                    speed,//速度
                    pitch,//變調值,默認1.0f
                    mSampleRate//輸出采樣率,一般不變
                    );

        ...
    }

    //把Mediacodec解碼音頻后的Frame數(shù)據(jù)數(shù)據(jù)在給到AudioTrack.write之前,先給到Sonic進行變速處理
    public void queueInput(ByteBuffer inputBuffer) {
        ...
        ShortBuffer shortBuffer = inputBuffer.asShortBuffer();
        ...
        sonic.queueInput(shortBuffer);
        ...
    }

    // 緊接著調用Sonic變速處理后的數(shù)據(jù)給到AudioTrack進行write
    public ByteBuffer getOutput() {
       ...
       int outputSize = sonic.getOutputSize();
        buffer =    ByteBuffer.allocateDirect(outputSize).order(ByteOrder.nativeOrder());
        shortBuffer = buffer.asShortBuffer();
        sonic.getOutput(shortBuffer);
        outputBuffer = buffer;
        ...
        return outputBuffer;
    }
}

可以看到SonicAudioProcessor就是AudioTrack和Sonic之前的一層封裝層。把Mediacodec解碼的音頻frame數(shù)據(jù)在給到AudioTrack.write之前,先通過queueInput給到Sonic進行變速處理,然后通過getoutput獲取處理后的數(shù)據(jù)再給到AudioTrack。

下面我們重點看下Sonic的queueInput和getOutput的實現(xiàn)。

public final class Sonic {

   private static final int MINIMUM_PITCH = 65;
    private static final int MAXIMUM_PITCH = 400;
    private static final int AMDF_FREQUENCY = 4000;
    private static final int BYTES_PER_SAMPLE = 2;

  public Sonic(
            int inputSampleRateHz, int channelCount, float speed, float pitch, int outputSampleRateHz) {
        this.inputSampleRateHz = inputSampleRateHz;
        this.channelCount = channelCount;
        this.speed = speed;
        this.pitch = pitch;
        rate = (float) inputSampleRateHz / outputSampleRateHz;
        minPeriod = inputSampleRateHz / MAXIMUM_PITCH;//最小的基音周期 44100/400
        maxPeriod = inputSampleRateHz / MINIMUM_PITCH;//最大的基音周期 44100/65
        maxRequiredFrameCount = 2 * maxPeriod;//最大的請求幀數(shù) 2* 44100/65  根據(jù)奈奎斯特采樣定律,采樣率為周期的2倍
        downSampleBuffer = new short[maxRequiredFrameCount];//下采樣的buffer
        inputBuffer = new short[maxRequiredFrameCount * channelCount];
        outputBuffer = new short[maxRequiredFrameCount * channelCount];
        pitchBuffer = new short[maxRequiredFrameCount * channelCount];
    }

    public void queueInput(ShortBuffer buffer) {
        ...
        processStreamInput();
    }

    private void processStreamInput() {
        ...
        float s = speed / pitch;
        float r = rate * pitch;
        if (s > 1.00001 || s < 0.99999) {
            changeSpeed(s);
        } 
        ...
    }

    private void changeSpeed(float speed) {
        ...
        int frameCount = inputFrameCount;
        int positionFrames = 0;
        do {
       //如果有保留的framecount,將inputbuffer 中保存的 positionFrames 個點的數(shù)據(jù)拷貝到 outputbuffer 中
          if (remainingInputToCopyFrameCount > 0) {
                positionFrames += copyInputToOutput(positionFrames);
          } else {
            //尋找基音周期
             int period = findPitchPeriod(inputBuffer, positionFrames);
             if (speed > 1.0) {
                 //如果倍速 進行跳幀重采樣
                 positionFrames += period + skipPitchPeriod(inputBuffer, positionFrames, speed, period);
              } else {
                 //如果慢速,則插入值
                 positionFrames += insertPitchPeriod(inputBuffer, positionFrames, speed, period);
                }
        } while (positionFrames + maxRequiredFrameCount <= frameCount);
        removeProcessedInputFrames(positionFrames);
    }


    private int findPitchPeriod(short[] samples, int position) {
        //尋找基音周期,這是變速不變調的關鍵的一步,Sonic采用 AMDF方式尋找
        int period;
        int retPeriod;
        int skip = inputSampleRateHz > AMDF_FREQUENCY ? inputSampleRateHz / AMDF_FREQUENCY : 1;//采樣率是否大于AMDF_FREQUENCY(4000),計算下采樣時,跳過的采樣點數(shù)量,這里的結果是5。為了提高效率,進行向下采樣到4KHZ,然后用更窄的頻率范圍再做一次。
        downSampleInput(samples, position, skip);
        period = findPitchPeriodInRange(downSampleBuffer, 0, minPeriod / skip, maxPeriod / skip);
         if (skip != 1) {
             period *= skip;
             int minP = period - (skip * 4);
             int maxP = period + (skip * 4);
             if (minP < minPeriod) {
                 minP = minPeriod;
             }
             if (maxP > maxPeriod) {
                 maxP = maxPeriod;
             }
             downSampleInput(samples, position, 1);
             period = findPitchPeriodInRange(downSampleBuffer, 0, minP, maxP);
            }
        if (previousPeriodBetter(minDiff, maxDiff)) {
            retPeriod = prevPeriod;
        } else {
            retPeriod = period;
        }
        prevMinDiff = minDiff;
        prevPeriod = period;
        return retPeriod;
    }

   //尋找基音周期的 最終實現(xiàn)就在這里了
  private int findPitchPeriodInRange(short[] samples, int  position, int minPeriod, int maxPeriod) {
        // Find the best frequency match in the range, and given a sample skip multiple. For now, just
        // find the pitch of the first channel.
        int bestPeriod = 0;
        int worstPeriod = 255;
        int minDiff = 1;
        int maxDiff = 0;
        position *= channelCount;
        for (int period = minPeriod; period <= maxPeriod; period++) {
            int diff = 0;
            for (int i = 0; i < period; i++) {
                short sVal = samples[position + i];
                short pVal = samples[position + period + i];
                diff += Math.abs(sVal - pVal);
            }
            // Note that the highest number of samples we add into diff will be less than 256, since we
            // skip samples. Thus, diff is a 24 bit number, and we can safely multiply by numSamples
            // without overflow.
           if (diff * bestPeriod < minDiff * period) {
                minDiff = diff;//計算最小差值
                bestPeriod = period;//對應對最佳基音周期
            }
            if (diff * worstPeriod > maxDiff * period) {
                maxDiff = diff;//記錄最大的差值
                worstPeriod = period;//記錄波形相似周期
            }
        }
        this.minDiff = minDiff / bestPeriod;//最小的差值 除以 最佳的基音周期,求得 采樣點的平均最小差值
        this.maxDiff = maxDiff / worstPeriod;//最大差值 除以 波形相似周期,求得采樣點的平均最大差值
        return bestPeriod;//返回最佳基音周期
    }

//如果是倍速處理,跳過基音周期信號
private int skipPitchPeriod(short[] samples, int position, float speed, int period) {
        // Skip over a pitch period, and copy period/speed samples to the output.
        int newFrameCount;
        if (speed >= 2.0f) {
            //大于等于2倍,不保留remainingInputToCopyFrameCount
            newFrameCount = (int) (period / (speed - 1.0f));
        } else {
            newFrameCount = period;
            //如果配速小于2倍,保留remainingInputToCopyFrameCount,采用線性插值法
            remainingInputToCopyFrameCount = (int) (period * (2.0f - speed) / (speed - 1.0f));
        }
        outputBuffer = ensureSpaceForAdditionalFrames(outputBuffer, outputFrameCount, newFrameCount);
        overlapAdd(
                newFrameCount,
                channelCount,
                outputBuffer,
                outputFrameCount,
                samples,
                position,
                samples,
                position + period);
        outputFrameCount += newFrameCount;
        return newFrameCount;
    }
//如果是慢速(小于1.0)則進行插入基音周期信號
  private int insertPitchPeriod(short[] samples, int position, float speed, int period) {
        // Insert a pitch period, and determine how much input to copy directly.
        int newFrameCount;
        if (speed < 0.5f) {
            newFrameCount = (int) (period * speed / (1.0f - speed));
        } else {
            newFrameCount = period;
            remainingInputToCopyFrameCount = (int) (period * (2.0f * speed - 1.0f) / (1.0f - speed));
        }
        outputBuffer =
                ensureSpaceForAdditionalFrames(outputBuffer, outputFrameCount, period + newFrameCount);
        System.arraycopy(
                samples,
                position * channelCount,
                outputBuffer,
                outputFrameCount * channelCount,
                period * channelCount);
        overlapAdd(
                newFrameCount,
                channelCount,
                outputBuffer,
                outputFrameCount + period,
                samples,
                position + period,
                samples,
                position);
        outputFrameCount += period + newFrameCount;
        return newFrameCount;
    }

    //最后進行合幀疊加處理,到輸出buffer
    private static void overlapAdd(
            int frameCount,
            int channelCount,
            short[] out,
            int outPosition,
            short[] rampDown,
            int rampDownPosition,
            short[] rampUp,
            int rampUpPosition) //rampUpPosition=rampDownPosition+基音周期值
        {
         for (int i = 0; i < channelCount; i++) {
            int o = outPosition * channelCount + i;
            int u = rampUpPosition * channelCount + i;
            int d = rampDownPosition * channelCount + i;
            for (int t = 0; t < frameCount; t++) {
                //把起始幀和基音周期幀的幀相加,這里采樣線性插值
                out[o] = (short) ((rampDown[d] * (frameCount - t) + rampUp[u] * t) / frameCount);
                o += channelCount;
                d += channelCount;
                u += channelCount;
            }
        }
    }

}

詳細說明見上述代碼注釋,基本流程總結如下:

  1. 首先確定一個最大和最小的基音周期范圍(和采樣率有關系的一個經(jīng)驗值)
  2. 通過findPitchPeriod找到基音周期大小,為了提高效率,先進行下采樣到4KHZ,然后用更窄的頻率范圍再做一次。尋找基音周期的方法就是:在 range 范圍內遍歷每個幀與起始幀的 AMDF 值,值最小的幀與起始幀的距離則是基因周期
  3. 根據(jù)倍速還是慢速分別進行跳過部分基音周期信號或者進行插入基音周期信號,
  4. 進行合幀疊加輸出到outputBuffer

調用以及l(fā)og輸出

   sonicAudioProcessor.queueInput(audioData);
   outData = sonicAudioProcessor.getOutput();
     
     Log.i(TAG, " inputDataLength="+audioData.limit()+ " inputData="+ Arrays.toString(audioData.array()));
     Log.i(TAG, "  outDataLength="+outData.limit()+ " outData="+ Arrays.toString(outData.array()));

--->0.5倍速時
inputDataLength=4096 
outDataLength=8096 //--》不是恒定的

--->1.5倍速時
inputDataLength=4096
outDataLength=2844 //--》不是恒定的

--->2倍速時
inputDataLength=4096
outDataLength=2020 //--》不是恒定的

可以看到0.5倍速時,進行了插值處理;大于1倍數(shù)時進行了采樣。這個的實現(xiàn)是

   do {
            //如果有保留的framecount,將inputbuffer 中保存的 positionFrames 個點的數(shù)據(jù)拷貝到 outputbuffer 中
            if (remainingInputToCopyFrameCount > 0) {
                positionFrames += copyInputToOutput(positionFrames);
            } else {
                //尋找基音周期
                int period = findPitchPeriod(inputBuffer, positionFrames);
                //找到基音周期后,變速的處理,重點時下面的skipPitchPeriod和insertPitchPeriod
                if (speed > 1.0) {
                    positionFrames += period + skipPitchPeriod(inputBuffer, positionFrames, speed, period);
                } else {
                    positionFrames += insertPitchPeriod(inputBuffer, positionFrames, speed, period);
                }
            }
        } while (positionFrames + maxRequiredFrameCount <= frameCount);

skipPitchPeriod的實現(xiàn)用下圖說明

insertPitchPeriod 的實現(xiàn)用下圖說明

由此可見,變速不變調不是簡單的改變采樣率,而是首先要找到基音周期,然后根據(jù)不同的倍速情況進行分幀、下采樣或者插值、合幀以及remainingInputToCopyFrameCount等處理。其中Sonic再尋找基音周期時采用 AMDF方式。
那么soundtouch又是如何實現(xiàn)的吶?我們下一篇來對其進行分析

三、資料

音頻變速變調 -sonic 源碼分析
語音識別 08 基音周期的估算方法

四、收獲

通過本篇的學習

  1. 了解了人是如何發(fā)生的,以及什么是基音周期
  2. 分析Exoplayer的Sonic變速不變調的實現(xiàn)
  3. 分析Sonic的通過平均幅度差函數(shù)法尋找基音周期的實現(xiàn)
  4. 分析變速的實現(xiàn)原理

感謝你的閱讀
下一篇我們繼續(xù)通過源碼分析另外一種變速不變調的實現(xiàn):Soundtouch,歡迎關注公眾號“音視頻開發(fā)之旅”,一起學習成長。
歡迎交流

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,182評論 6 543
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,489評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,290評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,776評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,510評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,866評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,860評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,036評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,585評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,331評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,536評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,058評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,754評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,154評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,469評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,273評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,505評論 2 379

推薦閱讀更多精彩內容