一個Android音頻文本同步的英文有聲讀物App的開發過程

「新概念英語」、「可可英語」、「亞馬遜的audible有聲書」、「扇貝聽力」是我目前所知道的實現英文語音和文本同步的應用。
「同步」包括兩方面:javascript

  • 被讀到的單詞(或句子)能夠高亮顯示,同步顯示文本;html

  • 選中某個單詞(或句子)跳到對應的音頻位置播放;html5

想要實現同步,須要知道每一個單詞(或句子)在音頻中的位置,稱之爲時間戳,相似於java

if(1.905669,2.0353742) you(2.0353742,2.1650794) really(2.1650794,2.4444444) want(2.4444444,2.643991) hear(2.643991,2.9333334) about(2.9333334,3.2226758) it(3.2226758,3.3024943)

手動去作顯然是件很是費力的工做,幸運的是,已經有研究人員實現了該功能,而且開源了軟件:
CMUSphinx Long Audio Aligner 項目主頁python

The aligner takes audio file and corresponding text and dumps timestamps for every word in the audio. (aligner能夠根據音頻和相應的文本,產生音頻中每一個字的時間戳)android

還有人作了進一步處理,把CMUSphinx生成的timing file格式化爲一個json文件,這樣:它的github主頁ios

"words": [
  ["if", 1.905669, 2.0353742], ["you", 2.0353742, 2.1650794], ["really", 2.1650794, 2.4444444], ["want", 2.4444444, 2.643991], ["hear", 2.643991, 2.9333334], ["about", 2.9333334, 3.2226758], ["it", 3.2226758, 3.3024943],

又有人在上二者的基礎上實現了一個網頁版的同步有聲書:HTML5 Audio Karaoke – a JavaScript audio text aligner
點擊這裏能夠觀看它的Demogit

而我但願作一個具備這樣功能的android app,播放本身喜歡的英文小說,練習聽力。github

如何使用Long Audio Aligner

$ git clone git@github.com:li2/TalkingBook21_AudioSync.git
$ cd aligner
$ python align-wav-txt.py demo/Unsigned8bitFormat.wav demo/raw.txt 
Running ant
Updating batch
Aligning text
Transcription: pumas are large catlike animals which are found in americawhen reports came into london zoo......

# --------------- Summary statistics ---------
   Total Time Audio: 112.00s  Proc: 3.28s  Speed: 0.03 X real time
<unk>(0.0,0.49) are(1.88,1.91) large(2.07,2.31) animals(2.34,2.94) which(2.94,3.15) are(3.15,3.29) found(3.29,3.67) in(3.67,3.76) americawhen(3.76,4.39) reports(5.22,5.92) came(5.92,6.3) into(6.33,6.66) london(6.66,7.09) zoo(7.09,7.42)......

# you can also execute:
$ python align-mp3-txt.py demo/OtherFormat.mp3 demo/raw.txt

這是一個命令行工具,它最終執行的是jar -jar bin/aligner.jar your/audio/file your/txt/file,python腳本align-wav-txt.py在其基礎上作了一層封裝。shell

這裏須要特別強調的是,aligner對音頻文件特別挑剔,遇到過的問題之一:耗盡計算機CPU,甚至超頻,最後java內存溢出

PID    COMMAND      %CPU  TIME     #TH   #WQ  #PORT MEM    PURG   CMPRS  PGRP  PPID  STATE    BOOSTS
17203  java         718.4 64:34.94 30/8  0    95    4276M- 0B     51M    17203 15729 running  *0[7]

$ ps aux | grep 17203
weiyi           17203 658.3 26.2  8298480 4392764 s000  R+   10:07上午  76:11.30 /usr/bin/java -jar bin/aligner.jar ../demo.wav ../demo.txt
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at edu.cmu.sphinx.decoder.search.AlignerSearchManager.collectSuccessorTokens(AlignerSearchManager.java:584)

問題二:只能同步一小部分文本
下面是我對網上下載的新概念英語3作的測試:

(章節號)  同步文本的時長/總時長
01  123/129
02  115/122
03  129/136
07  81/129
08  74/136
09  17/146
10  6/150
12  117/130
13  44/123
04,05,06,11,14,15,16,17,19,20,21,22,24,27    Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
18  54/140
23  171/178
25  0~100缺失
26  144/190

這兩個問題應該都和音頻格式有關。音頻編輯軟件audacity能夠導出音頻爲不一樣的格式,通過對比測試時發現,在導出對話框的format選項中,選擇「其它非壓縮音頻文件:文件頭WAV(Microsoft), 編碼Unsigned 8 bit PCM」,這種音頻格式可以獲得最佳的結果。
convert

因此我在demo文件夾裏上傳了兩種格式的音頻文件,用以對比。

參考I am not sure if this will be of any help to you, but I tried different audio formats as input with surprising results.

google 關鍵字

audio text alignment, audio text sync

aligner 依賴環境

Python 2.7, java, ant, sox,

若是你在執行腳本的過程當中,遇到錯誤,須要根據錯誤提示搜索緣由並安裝相關包,好比ubuntu12.04環境下執行align-mp3-txt.py時,提示錯誤:

no handler for file extension `mp3'

須要安裝libsox-fmt-mp3

處理Long Audio Aligner生成的Timing File

插入缺失的文本和標點符號

細心的你可能已經發現aligner生成的timing file其實是根據音頻文件轉譯的文本,與原始文本相比:無標點符號(包括段落分隔符);錯字(youre, dont, isnt之類缺失');漏字,等等。

因而我寫了一個python腳本parse_timing_json.py,它比較原文和aligner輸出的json文件,把原文未被識別的文本插入到json文件中,獲得一個包含完整文本和時間的json文件.
可是,這個腳本容錯性很是很差,或者說,對輸入文件很是挑剔,輸入json文件漏字不能太多,和txt文件必須幾乎一致,這個腳本才能夠把時間戳和原文作匹配。 幸運的是,long-audio-align產生的json文件能夠知足。

處理換行符和超過行寬的字符串

若是json文件中的字符串包含換行符,則以換行符爲界拆分字符串,換行符單獨拿出來。目的是方便app處理換行:app讀取到換行符後,使其佔據整個linearlayout,使app所顯示的文本段落結構更加清晰。
另外,若是字符串包含的字符個數超過5個,則拆分這個字符串。

# 若是你能正確執行align-wav-txt.py,那麼你會在demo文件夾中獲得一個名爲raw.json的文件(必定要使用align-wav-txt.py參生的完整的json文件):
$ cd parse_timing_json/
$ python parse_timing_json.py ../aligner/demo/raw.json ../aligner/demo/raw.txt
# 生成文件raw.json.out.json,

$ python parse_new_line.py ../aligner/demo/raw.json.out.json
# 生成文件raw.json.out.json.out.json

對比處理先後的json文件:

處理前:
 ["are", 1.88, 1.91], ["large", 2.07, 2.31], ["animals", 2.34, 2.94], ["which", 2.94, 3.15], ["are", 3.15, 3.29], ["found", 3.29, 3.67], ["in", 3.67, 3.76], ["americawhen", 3.76, 4.39], ["reports", 5.22, 5.92],
 
處理後:
["are", 2.07], ["large", 2.07], [", cat- like animals", 2.34], ["which", 2.94], ["are", 3.15], ["found", 3.29], ["in", 3.67], ["America. When reports", 5.22],

一個android英文有聲讀物app

在獲得了比較完整的timing file以後,剩下的工做是,如何呈現它。
初步構想是按頁呈現文本,支持自動翻頁和手動翻頁,經過ViewPager和Fragment實現。因此問題是:
給定一個文本,如何拆分紅頁(每頁鋪滿屏幕)?
好比能夠拆分爲3頁,第1頁包含21個單詞,第2頁包含22個單詞,第3頁包含23個單詞。若是獲得這些數據,那麼就很是容易構建界面了。

構建拆分文本的Adapter

public class ChapterPageAdapter extends FragmentPagerAdapter {
    // ViewPager的adapter的構造器,以文本文件的Uri做爲參數。
    public ChapterPageAdapter(Context context, FragmentManager fm, Uri jsonUri) {
        super(fm);
        mAppContext = context;
        assert context != null;
        mJsonUri = jsonUri;
        mPageBeginningWordIndexList = new ArrayList<Integer>();
        mPageBeginningWordTimmingList = new ArrayList<Integer>();
        splitChapterToPages(mJsonUri);
    }
    // 在拿到文本後,經過下面兩個private函數拆分文本,
    // 而拆分的關鍵是,根據屏幕的寬度、高度,每一個單詞的寬度,計算一個屏幕能夠顯示的單詞個數,用到的一些計算寬高度的方法被抽象成一個獨立的類`ChapterPageUtil.class`,
    // 最終獲得每頁首單詞在文本中的序號。
    private void splitChapterToPages(Uri jsonUri) {}
    private int totalWordsCanDisplayOnOnePage(List<String> words, int startIndex) {}

    @Override
    public int getCount() {
        // ViewPager託管的Fragment個數。
        return 首單詞序號的個數;
    }

    @Override
    public Fragment getItem(int poisition) {
        ......
        // 這就是ViewPager第position頁須要顯示的內容。
        ChapterPageFragment fragment = ChapterPageFragment.newInstance(uri, fromIndex, count);
        return fragment;
    }

每頁是一個Fragment

public class ChapterPageFragment extends Fragment implements OnClickListener {
    // Create fragment instance
    // 每頁對應一個Fragment,須要告知它要顯示的文本,從哪一個單詞開始顯示,總共顯示幾個單詞。
    public static ChapterPageFragment newInstance(Uri jsonUri, int fromIndex, int count) {
        Bundle args = new Bundle();
        args.putString(EXTRA_TIMING_JSON_URI, jsonUri.toString());
        args.putInt(EXTRA_FROM_INDEX, fromIndex);
        args.putInt(EXTRA_COUNT, count);
        
        ChapterPageFragment fragment = new ChapterPageFragment();
        fragment.setArguments(args);
        return fragment;
    }
    
    // Fragment是LinearLayout佈局,
    // 每一個單詞對應一個TextView,添加到 sub LinearLayout,填滿後再添加到下一個 sub LinearLayout(對應下一行);
    // 每一個換行符獨佔一個 sub LinearLayout。
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {}

實例化文本爲一個singleton

考慮到ViewPager Adapter和全部的Fragment都要用到文本信息,咱們把文本實例化爲singleton,便於文本數據取用。

public class TalkingBookChapter {
    // Singletons and centralized data storage
    private static TalkingBookChapter sChapter;

    // Setting up the singleton
    public static TalkingBookChapter get(Context context, Uri timingJsonUri) {
        if (sChapter == null) {
            sChapter = new TalkingBookChapter(context, timingJsonUri);
        }
        return sChapter;
    }

    private TalkingBookChapter(Context context, Uri timingJsonUri) {}
    // 下面兩個函數就是爲了Fragment取它所須要顯示的文本。
    public List<String> getWordList(int fromIndex, int count) {}    
    public List<Integer> getTimingList(int fromIndex, int count) {}    
    public int size() {}

如何在點擊單詞或者翻頁時跳轉到對應的音頻位置

Fragment定義一個interface,在TextView被點擊時調用,Activity實現它完成音頻位置的調節。
翻頁的時候,就更簡單了,只須要override ViewPager的OnPageChangeListener.

public class ChapterPageFragment extends Fragment implements OnClickListener {
    private OnWordClickListener mOnWordClickListener;    

    public void setOnWordClickListener(OnWordClickListener l) {
        mOnWordClickListener = l;
    }

    public interface OnWordClickListener {
        public void onWordClick(int msec);
    }

    @Override
    public void onClick(View v) {
        if (v instanceof TextView) {
            int msec = (int)v.getTag();
            if (mOnWordClickListener != null) {
                mOnWordClickListener.onWordClick(msec);
            }
        }
    }
}

public class FullScreenPlayerActivity extends FragmentActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        mChapterPageAdapter = new ChapterPageAdapter(this, getSupportFragmentManager(), mTimingJsonUri);
        mChapterPageAdapter.setOnChapterPageWordClickListener(mOnPageAdapterWordClickListener);
        mChapterViewPager.setAdapter(mChapterPageAdapter);

    // 這個Activity管理ViewPager,Fragment定義的interface被ViewPager的Adapter又包了一層,
    // 因此當Fragment的TextView被點擊後,最終會調用到這裏:
    private OnChapterPageWordClickListener mOnPageAdapterWordClickListener = new OnChapterPageWordClickListener() {
        @Override
        public void onChapterPageWordClick(int msec) {
            seekToPosition(msec); // 這個函數調用到 MediaPlayer.seekTo(int msec),調節到指定的音頻位置。
        }
    };

    private ViewPager.OnPageChangeListener mOnPageChangeListener = new OnPageChangeListener() {
        @Override
        public void onPageSelected(int position) {
                ......
                seekToPosition(mChapterPageAdapter.getPageTiming(position)); // 翻頁時,調節到指定的音頻位置。
        }

如何高亮當前讀到的文本

public class FullScreenPlayerActivity extends FragmentActivity {
    // 這是一個更新界面的定時任務,
    private void updateProgress() {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                int msec = mPlayerController.getCurrentPosition();
                mChapterPageAdapter.seekChapterToTime(msec);
                // check if seeking time is out of selected page, if true, then set ViewPager to current item.
                // 若是已經讀到下一頁,那麼執行ViewPager.setCurrentItem()
                ......
            }
        });
    }

public class ChapterPageAdapter extends FragmentPagerAdapter {
    // This method will be called to notify fragment to update view in order to highlight the reading word.
    public void seekChapterToTime(int msec) {
        mSeekingTime = msec;
        notifyDataSetChanged(); // 調用這個方法後,getItemPosition()會被調用,咱們在getItemPosition()中完成Fragment界面的更新。
    }

    @Override
    public int getItemPosition(Object object) {
        if (object instanceof ChapterPageFragment) {
            // This method called to update view in order to highlight the reading word.
            ((ChapterPageFragment) object).seekChapterToTime(mSeekingTime);
        }
        return super.getItemPosition(object);
    }

關於章節列表對應的類的說明 TODO

關於播放器的說明 TODO

用到的開源軟件

  • android-UniversalMusicPlayer
    這是一個開源的android音樂播放器,它的項目主頁

個人播放器代碼不少直接拿了它的一個文件 FullScreenPlayerActivity.java 點擊查看

  • ViewPagerIndicator
    這是一個開源的Android UI,用以標示ViewPager的頁,就是常見的幾個小圓點。

它的項目主頁

可是呢,它包含上百兆的音頻文件,在時好時壞的國外網站訪問現實下,有時只有幾kb的下載速度,我就folk它而後刪掉它的音頻文件。這樣子。

去哪下載

2015年08月21日完成了1.0版本,App名字叫TalkingBook21(由於我叫li21嘛),你能夠在這裏下載App
吶,它是這個樣子的:
demo
demo

因爲音頻文件很大,因此只在app裏包了一個音頻,權當是個demo。
更多的音頻須要在這裏下載,目前僅實現了《麥田的守望者 The Catcher in the Rye》的同步,音頻總時長7個小時,293M。
因此坦白的講,這個app實質上是麥田的守望者音文同步有聲讀物Android App.

你須要把它解壓後放入手機的外置SD卡:YourExtSDCard/TalkingBook21/,而後從新啓動App(完全殺掉!),重啓後的App會向你展現章節列表,點擊便可播放。

這裏是app的源碼
這裏是製做timing json file的開源命令行工具


版權聲明:《一個Android音頻文本同步的英文有聲讀物App的開發過程》由 WeiYi.Li 在 2015年08月16日寫做。著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。
文章連接:http://li2.me/2015/08/my-app-android-tal...

相關文章
相關標籤/搜索