跳轉到

語音助理的基本組成

封面

在這邊需要介紹一些專有名詞。一套語音助理系統的組成,通常會包括—ASR、NLU、NLG、TTS 等,在一個完整的語音互動中,扮演各自的角色。

  • ASR:Automatic Speech Recognition,自動語音識別,又稱為 STR (Speech-to-Text),負責將語音訊號轉換為文字。對於行動開發者來說,應該清楚蘋果提供了 Sppech 框架,在 Android 系統中也有 SpeechRecognizer 可用,在瀏覽器中也一樣有 SpeechRecognition。除此之外,許多雲端服務,像是 Google Cloud 與 Azure,也具備將上傳的語音資料轉換成文字的服務。
  • NLU:Natural Language Understanding,自然語言理解,負責從文字中抽取出意圖,像是知道使用者想要做什麼事情,想要問什麼問題,也可以分析使用者所說的話當中的情緒(悲傷、高興、憤怒),或是口吻是正式還是輕鬆等。在行動開發的框架中,通常沒有這部份的框架,但是所有的交談型 LLM 都一定包含 NLU 的能力。此外,比較有名的 NLU 引擎包括 Google DialogflowMicrosoft LUISIBM WatsonNuance Recognizer 等等。
  • NLG:Natural Language Generation,自然語言生成,負責產生對特定文字的回覆。其實各種 LLM,就是在擔任 NLG 引擎的工作。
  • TTS:Text-to-Speech。負責將文字轉換為語音。在行動開發者的框架中,蘋果提供了 AVSpeechSynthesizer、Google 提供了 TextToSpeech。在瀏覽器中,也有 SpeechSynthesis 可用。雲端服務中,像是 Google Cloud 與 Azure 也提供了將文字轉換成語音的服務。

各種組件的分工

在執行一段語音助理的工作流程中,各個組件的分工如下:

使用者使用者AppAppASRASRNLUNLU對話引擎對話引擎NLGNLGTTSTTS輸入語音輸入語音產生辨識出的文字輸入文字內容從文字中抽取意圖(Intents)與相關的 Slots將意圖交給對話引擎alt[是語音助理應該處理的意圖]詢問可能影響互動結果的 App 當前狀態alt[可以執行這項工作]要求 App 執行這項工作產生回應產生回應將回應交給TTS 引擎使用聲音回應使用者[超出範圍之外]將原本的句子交給 NLG 引擎產生回應將回應交給TTS 引擎使用聲音回應使用者

在上圖中,多了一個叫做「對話引擎」的角色,也就是我們需要自行開發的部分。對話引擎需要負責管理一份對應表格(mapping),將各種從 NLU 引擎抽取出的意圖,對應到我們的語音助手可以執行的工作上。如果不能處理這項工作(像使用者只說了「你好」,但是我們沒辦法用「你好」這句話決定可以做什麼事),我們可以有幾個選擇,或是就直接用 TTS 告訴使用者我們不理解你的意圖,或是就由 NLU 引擎產生一個回答,這樣會讓使用者感到比較親切。

至於是可以執行的工作,就會走入一套我們設計好的對話流程。我們在後面再詳細討論。

狀態

從以上的流程圖中,我們也可以發現,對話引擎擁有以下幾種狀態:

  • 閒置狀態:也是語音助理的初始狀態。
  • 聆聽/辨識狀態:使用者從 App 啟動了麥克風,對系統輸入語音資料,同時 ASR 引擎也在嘗試辨識語音。通常等到使用者停止講話一陣子,我們架設使用者已經把想講的話說完,我們就會進入下一個狀態。而如果在一段時間內,使用者什麼話都沒說,或是沒有任何 ASR 辨識結果,我們也會離開這個狀態,回到閒置狀態,我們也可能用 TTS 提示使用者「我不能理解你的意思」。
  • 處理狀態:我們將 ASR 辨識結果送到 NLU 引擎後,NLU 引擎往往需要一段時間分析,我們也往往需要獲得一些其他的資訊,才知道對話應該如何繼續進行。這段時間,我們通常會在 App 上顯示一個等待的畫面,或是用 TTS 提示使用者「我正在處理你的要求」。
  • TTS 播報狀態:當我們用 TTS 回應使用者時,我們會進入這個狀態。這個狀態通常會持續一段時間,直到 TTS 播報完畢,我們才會回到閒置狀態。或是,如果我們需要繼續追問使用者,我們會回到聆聽/辨識狀態。
IdlingListeningProcessingTtsPlaying啟動語音助理沒有輸入使用者停止講話播放 TTS 回應播放完畢播放完畢,但繼續追問使用者

這些狀態還可以繼續細分,像是前面提到,處理階段中,也可以繼續拆成 NLU 處理中,或是在獲取額外資訊等階段。而 App 應該要監聽這些狀態,並且在不同的狀態下,顯示不同的畫面,或是提供不同的操作,以符合使用者的預期。

@immutable
abstract class DialogEngineState {}

/// Idling state
class DialogEngineIdling extends DialogEngineState {}

/// Listening state
class DialogEngineListening extends DialogEngineState {
  final String asrResult;
  DialogEngineListening({required this.asrResult});
}

/// Processing state
class DialogEngineCompleteListening extends DialogEngineState {
  final String asrResult;
  DialogEngineCompleteListening({required this.asrResult});
}

/// TTS playing state
class DialogEnginePlayingTts extends DialogEngineState {
  final String prompt;
  DialogEnginePlayingTts({required this.prompt});
}

介面設計

我們可以使用的各種 ASR、NLU、NLG、TTS 的服務非常多,除了各種已有的選擇之外,我們甚至可能會開發自己的服務,像是在我們自己的伺服器上放置我們自己調整過的模型。從開發對話引擎的角度來看,我們便應該專注於介面,而非個別服務的實作,我們只需要知道 ASR、NLU…每個引擎所具備的能力,之後可以隨時抽換實作。我們的對話引擎大概會像這樣:

  • 有 ASR、NLU、NLG、TTS 四個引擎的實例
  • 有可以讓外部監聽的狀態
  • 有可以讓外部設定的對話流程
class DialogEngine implements VuiFlowDelegate {
  final AsrEngine asrEngine;
  final TtsEngine ttsEngine;
  final NluEngine nluEngine;
  final NlgEngine nlgEngine;

  final StreamController<DialogEngineState> _stateStream = StreamController();
  DialogEngineState _state = DialogEngineIdling();
  DialogEngineState get state => _state;
  Stream<DialogEngineState> get stateStream => _stateStream.stream;

  Map<String, VuiFlow> _vuiFlowMap = {};
}

ASR

我們定義的 ASR 引擎像這樣:

enum AsrEngineState {
  listening,
  notListening,
  done,
}

abstract class AsrEngine {
  Future<bool> init();
  Future<bool> startRecognition();
  Future<bool> stopRecognition();
  Future<void> setLanguage(String language);

  Function(String, bool)? onResult;
  Function(AsrEngineState)? onStatusChange;
  Function(dynamic)? onError;
  bool get isInitialized;
}

在 ASR 引擎的介面上,最主要的 method 就是初始化、開始辨識、停止辨識,以及設定語言。我們也提供了一些 callback,讓外部可以監聽 ASR 引擎的狀態。因為在開啟 ASR 引擎之前,可能需要做一些權限相關的設定,因此設計了一個初始化的 method,而在啟動 ASR 引擎之後,對話引擎就會進入 Listening 狀態,這時候就會開始接收語音輸入,透過 onResult 接收目前辨識出的文字。當使用者停止講話,或是 ASR 引擎辨識出一段語音,我們就會進入 Done 狀態,這時候就可以將辨識結果送到 NLU 引擎。

NLU

class NluIntent {
  final String intent;
  final Map slots;

  NluIntent({
    required this.intent,
    required this.slots,
  });

  factory NluIntent.fromMap(Map json) {
    return NluIntent(
      intent: json['intent'] ?? '',
      slots: json['slots'] ?? [],
    );
  }
}

abstract class NluEngine {
  Set<String> availableIntents = <String>{};
  Set<String> availableSlots = <String>{};

  Future<NluIntent> extractIntent(
    String utterance, {
    String? currentIntent,
    String? additionalRequirement,
  });
}

如前所述,NLU 引擎的角色就是從文字抽取出意圖。所以我們在這邊定義了 NluIntent 物件,在這個物件中,包含被抽取出的意圖的代號,以及與這個意圖相關的 Slot—所謂的 Slot 就是意圖中的參數。例如「導航到動物園」這句話中,「導航」是使用者想要執行的意圖,而「動物園」就是這個意圖的 Slot。

NluEngine 介面中,我們定義了一個 method extractIntent,這個 method 會接收一段文字,並且回傳一個 NluIntent 物件。在這個 method 中,我們也可以設定一些參數,像是目前的意圖、或是一些額外的需求,讓 NLU 引擎可以更好地抽取出意圖。像是,我們告訴 NLU 引擎我們想要哪些 NLU 意圖以及 Slot,就可以幫助他盡可能歸類到我們限制的分類中。

NLG

abstract class NlgEngine {
  Future<String?> generateResponse(
    String utterance, {
    bool useDefaultPrompt = true,
    bool? preventMeaningLessMessage,
  });
}

NLG 的介面非常簡單,基本上就是將一段文字交給 NLG 引擎,並且回傳一段回應。在這個 method 中,我們也可以設定一些參數,像是是否使用預設的提示,或是是否要避免回傳無意義的訊息。

TTS

TTS 引擎所需要的介面,就是播放某個句子以及與停止播放。此外,我們通常需要能夠設定 TTS 引擎的語言、語速、音量、音高,以及聲音(男聲、女聲等)。我們另外設計了一些 callback,讓外部可以監聽 TTS 引擎的狀態。

import 'dart:async';

abstract class TtsEngine {
  Future<void> playPrompt(String prompt);
  Future<void> stopPlaying();
  Future<void> setLanguage(String language);
  Future<void> setSpeechRate(double rate);
  Future<void> setVolume(double volume);
  Future<void> setPitch(double pitch);
  Future<void> setVoice(Map<String, String> voice);

  Function()? onStart;
  Function()? onComplete;
  Function(String text, int startOffset, int endOffset, String word)?
      onProgress;
  Function(String msg)? onError;
  Function()? onCancel;
  Function()? onPause;
  Function()? onContinue;
}