MF99 coding 💻

keep learning; keep coding;

智慧音箱 OS 設計

前言

2015 年,Amazon Echo 讓世人展現了 Smart Speaker 的威力, 2016 年 Google 也推出了自己的 Google Home 系列,頂著 IoT Smart Hub 的光環以及利用藍牙音箱這個已知需求為載體,智慧音箱一躍成為當紅炸子雞。

在這個背景下,當時作為專注於提供兒童/父母數位產品的公司,也決定製作一款面對新世代新手爸媽與學齡前兒童的智慧音箱產品。

f:id:mouseface99:20200707103802j:plain

不過,這個產品一做就是兩年(雖然最後因為隱私問題,首批量產了但是最終沒有上市.....),而且由於多種商業因素,這個產品的軟體部分在公司內部翻掉重做了至少 3~4次。 不過也因為如此,每一次重做時,我們技術團隊都能夠記取過去的教訓,一次次的完善這個產品的設計與架構。

這裡就來分享一下,我們當年大改小改不知道多少次之後最終的設計概念與架構。 雖然不一定就真的是 Amazon 或是 Google 音箱內的設計(至少他們應該不會用肥大的 Android OS),但至少也給想開發音箱,或是類似平台的團隊一點參考方向。

產品介紹

大概介紹一下這個產品,基本上就是一個藍牙音箱,但是在硬體上針對育兒特性,在中心加上了一個可以當作夜燈的三色 LED,可以透過配置調出不同的顏色與亮度,並且還包含了 Android / iOS 的 App,一來可以在初始化時透過藍芽幫音箱配對綁定,並設定 Wifi 連線。 App 也可以透過操作介面來調整音箱上的硬體設定與配置(音量大小聲、夜燈顏色亮度等)。 另外,音箱也包含了最核心的語音控制功能,透過特定的關鍵字喚醒後,可以利用自然語言來使用音箱。

可以操作硬體(播放音樂、開關燈、調整音量),或是我們自家設定針對育兒環境或是兒童設計的一些特殊功能(哭聲偵測、講故事,簡單的數學問題,紀錄換尿布/餵奶時間),當然也可以詢問一些常見的常識性/資訊類問題(天氣、講笑話、設定鬧鐘提醒)

所以開發上,這個產品的開發會分佈在三個核心部門:OS 團隊,Mobile App(iOS/Android)團隊以及 Backend 團隊。 這篇文章主要會著重於 OS 團隊的設計與實作架構。

產品架構

整體來看,各個元件的相互關係如下:

f:id:mouseface99:20200707105225p:plain

中心的部分就是智慧音箱,一開始用戶透過手機/藍芽進行配對、Wifi 設定後,用戶就可以透過語音與之互動,或是跟 Server 之間做資料交換,Server 也可以透過 MQTT push 訊息到音箱中,另外他也可以透過開啟後即時監測環境中是否有嬰兒的哭聲。

核心架構設計

音箱作為一個訊息與事件的處理中心,要滿足上面的功能,以及考慮到模組化、可監控/可自我修復以及未來更新方便,核心的設計方向為把所有的元件分成四大類:

種類 功能
Event receiver 事件接收器,用來處理特定事件(按鍵觸發、用戶說話、MQTT訊息等)
Action Handler 當系統解析確認要做的具體行為後,透過 Action Handler 對特定硬體做實際的操作(開關燈、播放音樂、語音合成等)
Action Dispatcher 串接 Receiver/Handler 並且根據 Action 屬性派發到特定的 Action handler
Watchdog 獨立 Service,用來監控與紀錄整體系統的運作狀況,並且視情況進行自主修復或是通報

f:id:mouseface99:20200707102302p:plain

舉例來說,用戶可以透過語音跟音箱說「開燈」,或是可以透過音箱上的按鈕開啟音箱的夜燈,或甚至偵測到嬰兒哭聲後自動開燈。

  • STT Receiver 的作用就是負責聆聽用戶透過語音說的指令,並且透過後端的自然語言處理與分析後,確定了這是要開燈的 Action,透過 MQTT 發送 Action 給音箱(MQTT Receiver 收到後發送Action 給 Action Dispatcher)
  • Button Receiver 偵測到了按鈕按下後,發送 Action 給 Action Dispatcher
  • Cry Detect Receiver 實時監控環境聲音,當觸發了嬰兒哭聲後,透過規則庫知道目前要進行開燈的動作,就發送 Action 給 Action Dispatcher

STT 模塊如何將用戶講的自然語言轉化為特定的 Action 流程,可以參考之前的文章 語音助理,Chat Bot 實作原理

接著 Action Dispatcher 收到此 Action 後(不需要知道來源為何),找到特定的 Light Handler 並把 Action 物件(包含了一些特定參數:例如 RGB 顏色,亮度等資訊)發送給 Handler

Light Handler 收到 Action 後,透過物件內指定的參數操作硬體的 LED 並顯示指定的顏色與亮度。

從這個例子中,我們把音箱可能會接收或是執行的動作拆分並抽象為 Receiver / Handler 的結構,然後中間透過 Action Dispatcher 來做派送/轉發,這樣的結構除了把 Receiver / Handler 的耦合度降低以外,另外這個結構未來如果要套用到不同的音箱產品線,不同硬件有不同的硬體配置,就可以動態的配置不同的 Receiver / Handler 模塊。

並且,每個不同的 Receiver / Handler 都是獨立模塊,可以單獨的測試與更新。

落地方案

有了設計架構後,再來就是要考慮具體的落地方案,在我們這個案子中使用的音箱基礎 OS 為 Android,並且我們手中有可修改的 Source code 與編譯環境,所以我們就選擇使用 AOSP 的 codebase 並且將我們的設計架構,運用 Android 現有的一些機制整合到最終的音箱 OS 中。

這也是當初在開發/設計過程中最花時間的部分,因為一來我們要對 Android OS 本身的架構有基本的理解,然後根據我們所需的元件特性,如何利用 Android 現有的一些機制來實現。

同時又要滿足能夠模塊化,而且我們還希望能夠導入到 CI / Auto test 的流程。 所以最後的實作架構如下:

(右邊為參考的 Android Architecture) f:id:mouseface99:20200708002217p:plain

這邊分成幾個部分來解說

Main Service

核心 Server,作為 Android System Service 的一部分,開機後自動被啟動,並且持續在背景運行

Main Service 的主要功能如下:

  • System initializer:在開機時,初始化所有的 Event Receiver / Action Handler,以及定期的監測/更新所有元件的健康狀態
  • Action Dispatcher : Event Receiver 可以透過 Context.getSystemService() 的方式拿到其實體,然後把 Action 物件發送給它,然後根據 Action 的種類,發送給特定的 Action Handler

System App

這部分主要是 Event Receiver 與 Action Handler 的實作,透過 System App 的方式實作的目的為,讓每個 Receiver / Handler 的個體為單一的 APK,這樣就能夠實現模塊化。並且能夠獨立開發、更新與測試。

並且雖然 System App 的 APK 一開始是 built-in 在出廠的 System image 中,但是後續可以透過 APK update 的方式更新個別元件,而不需要動用到 System Update。

Event Receiver

Event Receiver,作為音箱對外界的接口,需要在產品運作時常駐在系統中,但是由於其中包含了業務邏輯以及或許會跟某些 IO 有依賴關係,所以自然不能用到 System service 的層級,而且作為需要封裝成 System App,所以實作上就選擇了 persistent Service,但是在剛開機時不會馬上啟動(因為有些依賴的 IO 或是其他元件/子系統需要先被系統初始化),所以在實作上我們使用了定義一個 Event Receiver 共通的 INTENT_ACTION 來負責初始化。

當 Main Service 啟動,初始化完成後,就會透過這個 INTENT_ACTION 來啟動所有的 Event Receiver Service

定義這個抽象的 ACTION 的好處是,就算在不同配置的系統下, Main Service 也不需要知道特定 Service,只要透過一種 Action 就可以初始化所有的 Event Receiver 然後讓他們自行啟動/運作

所有 Event Receiver 啟動後,就會根據各自不同的需求,初始化自己相對應的服務或是對外連接,像是 STT 模塊就需要開啟麥克風開始監聽用戶的關鍵啟動詞,另外也要跟自然語言處理的後台建立連線。 MQTT 模塊就要建立 MQTT 的連線。 Sensor 模塊就要開始監聽硬體的 Sensor data 等。

Action Handler

作為 Event Trigger 屬性的 Action Handler,我們團隊在摸索與研究後,最終選擇使用 Intent Service 來實作,這樣有個好處,由於 Event Receiver 是由系統負責初始,並且需要保持常駐。 但是 Action Handler 基本上是有 Action 需要處理才需要被調用,而且有時候有可能會發生多個 Action 需要連續處理,如果單純是用 Service 的話, Thread 的數量與維護就必須要自己掌握,或是要自己處理 Task queue 的維護。

所以這時候 Intent Service 的優勢在於,已經實作好了 Background Thread,並且維持在單一 Thread,然後自帶了 Intent queue 能夠把多個 Intent 依序處理。

所以對於 Action Dispatcher 來說,只要看到對應的 Action 然後把它封裝成 Intent 丟出來就好,對應的 Handler 就能夠自行處理。

另外,這邊的 Action 也就直接對應到了系統中的 ACTION_NAME,所以 Main Service 其實也不會知道具體是哪一個 Action Handler (甚至有可能是 0 個或多個)在處理這個 Action,他就是收到了需求,然後就發出 Action Intent 就好。

與 Event Receiver 不同, Action Handler 是被有 Action 觸發才會被調用的,所以有些 Handler 內部有需要對接其他外部服務或是 SDK ,就要想辦法去優化初始化的流程或是想辦法讓它變成 Singleton。

舉例來說我們在 STT/TTS 的功能用到了外部的第三方 SDK,所以我們就把 STT Receiver 與 TTS Handler 封裝在同一個 APK 中,所以在 STT Receiver 啟動時就會將 SDK 的初始化完成,這樣在 TTS Handler 需要時就可以直接調用。

Watchdog Service / Agent

作為會實時常駐並且沒有顯示螢幕的音箱設備,不管是對於用戶或是服務提供商,能夠及時掌握系統的健康狀況都是非常重要的。

所以在我們設計的這套系統中,也有一個獨立運作的 Watchdog System Service 來負責記錄,與監控系統的運作狀況。

具體作法,我們會設計一個通用的健康狀況資料格式,然後實作一個 Agent 並放在各個 Event Receiver / Action Handler 模塊中。 這個 Agent 主要提供兩個功能:

  1. 在運作時即時回報
  2. 定時由 Main Service 詢問健康狀況

主動即時回報

當 Event Receiver 收到 Event,或是 Action Handler 收到了要被執行的 Action 時,就會將此資訊封裝(必要的話還會紀錄執行結果),然後透過 Agent 回報到 Watchdog 中的 system log 模塊,這個模塊也會根據網路狀況,定期地將 log 回傳到服務後端以供未來追查或是分析。

被動健康檢查

Main Service 會定時地發出一個特定的 Broadcast,然後一樣所有的 Receiver/Handler 模塊都會監聽這個 Broadcast,然後收到後將當前模塊的健康狀況(預先定義好的格式)回報給 Main Service。

由於 Main Service 在初始化的時候會透過一個 Intent Action 來 Query 出此裝置中所有的 Receiver/Handler 模塊列表,並且再把實時監控的健康狀況回報列表,彙整到 Watchdog Service 中的 Health Check 模塊,就可以知道目前各個模塊的健康狀況(同時也知道是否有哪些模塊一直沒有回報)

這時候就可以透過一個 Recovery policy (可在本地寫死,也可以從後端動態更新),來決定是否要進行自我修復。

自我修復的機制包含了兩個層級:

  1. 模塊層級
  2. 系統層級

如果錯誤狀況集中在某個模塊(通常會是 Event Receiver Service), Watchdog 就會通知 Main Service,然後將此 Service 砍掉重啟(System Service 權限很大!)

如果錯誤的範圍很大,甚至連 Main Service 的狀況都不太好的情況下, Watchdog 就會調用 Kernel 的底層功能,直接重啟整台機器(跟電腦一樣,當機就是重開就對了)

如果重開了狀況還是依舊,可能就要開大絕了。 就是將系統燈號顯示為錯誤,然後用戶有任何互動的時候都會播放一組預設的音檔,請他聯絡客服人員了。

然後我們可能就會需要根據這個 Device ID 過去的行為,來判斷如何排除問題,或最差的就是由客服引導用戶執行 Ractory reset

CI

談完了系統設計與實作,再來有一塊也是困擾了我們很久的,就是如何讓這樣的產品,能夠有效的管理以及導入到 CI 和 Auto Testing。

過去在代工廠的時候,一個產品的 Codebase 基本上就是一大包,然後放在一個巨大的 git 中(是的,除了 Google 很少人放到多個 git 然後用 repo 來同步),然後所有人就是 clone 一大包在電腦上,整包 build 然後做出 system image,然後自己 flash 到測試機並測試。

但是在我們這套系統中,我們希望為未來規劃一個能夠有著多種配置/版本音箱的結構,並且對於每個 Event Receiver / Action Handler 都能夠當成一個獨立模塊進行開發與管理,所以具體 Source code 的拆分如下:

f:id:mouseface99:20200707234845p:plain

由於根據不同的硬體與 BSP/Kernel,會有多個配合硬體的 Base codebase (主要是 BSP + AOSP 的部分,大多都是我們不會去動到的 code,但是又有可能根據 Vendor or Google 的 update patch 更新,所以還是要有個地方來管理)

然後各個 Receiver/Handler 模塊都可以分別使用獨立的 git repository (Gitlab project),所以可以透過純 app 的方式來管理與獨立測試。Main/Watchdog Service 除外,因為這兩個 Service 無法獨立運作,只能在整合測試的時候才能測試(不過我們的確會不定期把裡面的一些特定功能單獨拉出來做測試以保證一些特殊的 test case 都能夠被成功處理)

最終會建立一個獨立的 Git project 來負責 CI Runner 的任務與狀態監控,並且另外設置一台獨立於 Runner 的 Build machine

由於在 Build Android image 的時候,要盡可能的調用所有的 CPU / Memory 資源,所以如果跟 Runner 在同一台機器的話,整個 CI Runner 的功能就會變得不穩定,甚至斷線。 所以比較好的做法是把機器區隔開來。而且 Build machine 就可以想辦法用好一點的電腦以及安裝乾淨的 Linux 環境

再來看看具體的 CI 流程:

f:id:mouseface99:20200707121917j:plain

Stage 功能 說明
0 Request Build 透過 CI runner project,發起 build request 並指定 Project code
1 Verify config 透過 project code 在 Config table 取得並驗證對應的配置(其中包含了 Base codebase 的位置,包含哪些 Receiver / Handler 模塊等)
2 Fetch & Merge 根據 Config 中指定的元件,在 Build machine 上建立完整的 codebase,順序為:
1. Clone 對應的 Base codebase 以及 Main/Watchdog Service 的 repo
2. Clone 各個 Receiver / Handler 模塊的 code 到 codebase 中
3 Build 所有的 code 都 merge 完成後,執行完整的 clean build
4 Flash & Test 確定有 build 出來後,自動執行 flash 指令,將 image 燒在預先連接在 build machine 的測試機上。
並執行預先寫好的 Test case / script
5 Generate OTA package & upload 確定都測試完成後,透過剛剛 build 好的中間檔(target file),生成 OTA package 並上傳到 OTA Server 中

正常上線的產品,除非要更新工廠的 shipping image,不然大多就是透過 OTA package 來對用戶進行更新。 所以其實 build 出來的 image 大多只是用來做內部測試用,真正交付的 deliverable 其實是 OTA package

另外在整個過程中,雖然大部分的運作都是在 Build machine,但是實際上 CI Runner 還是會監控每一個步驟的成功失敗,一來回報/更新 GitLab CI 的介面,二來也可以決定是否要走到下一個步驟(比方說 Codebase merge 失敗就不會進行 clean build,測試失敗的話就不會產生 OTA Package等)

後記

這個案子算是目前我工作至今,規模最大的一個案子了。 因為除了產品本身就跨越了 Server / App / Speaker 之外,中間又整合了多個不同的 3rd party vendor,以及要跟 OEM 廠商合作與把控產線的品質。

加上因為一些技術與政治因素,這個案子裡面的 Solution 被換了好幾次,導致整個系統的實作也大大小小翻掉重做了不知道多少次。 但是像我前面說的,每一次重做我們團隊都會根據前一次的經驗,設計出更有彈性,執行效率更好,或是開發起來更方便(像是 codebase 拆分,CI 之類的)的結構。 最終的設計也跟最原始的版本落差很大。

雖然最後還是沒有撐到正式上線,但是還是一個很寶貴跟難得的經驗。 也在這個專案的過程中認識了許多國內外的優秀團隊(微軟、Silk Labs、Pull String、緯創、Chicony、IBM、科大訊飛、阿里巴巴)

同時也在這個案子中,透過團隊的合作,討論。更了解了 Android 系統與 GitLab CI 的特性與使用。 也大大的累積了不少關於龐大系統架構設計的經驗。