---
# System prepended metadata

title: Automatic Speech Recognition

---

# **Automatic Speech Recognition**

## 動機

我們專題打算做一款語音導遊助理，提供使用者簡單的對話環境，協助獲取附近景點、餐廳、旅館等資訊。我們要做的第一個功能即為語音辨識，我們該如何自動即時得知使用者所說的話進行處理，語音辨識成為我們第一個要解決的關卡。

## 使用套件


### 1.docker及系統相關

docker image:[pytorch/pytorch](https://hub.docker.com/r/pytorch/pytorch)
開發環境(IDE):[jupyter](https://jupyter.org/)

### 2.訓練及前處理相關

librosa:[librosa](https://librosa.org/) 
&emsp;python套件，用於分析音頻信號，亦可用於音頻處理，將音訊轉為已正規化的特徵。
hugging face:[hugging face](https://huggingface.co/)
&emsp;huggingface是一款人工智慧開發平台，提供使用者共享訓練資料，以及分享訓練或微調的模型。可透過該平台所提供的API下載模型、數據，也可以查看其他使用者設計出的模型使用的參數等等。
&emsp;datasets:[datasets](https://huggingface.co/docs/datasets/index)
&emsp;&emsp;hugging face所提供的資料集整合、處理及分享套件。

### 3.模型

Pytorch:[官方網站](https://pytorch.org/)
&emsp;本專題使用所有模型都需要相依此套件，採用cuda版本為12
transformers:[官方網站](https://huggingface.co/docs/transformers/index)
&emsp;本專題使用wav2vec2模型，需要透過此套件來進行設計並使用。
wav2vec2:[說明文件](https://huggingface.co/transformers/v4.9.2/model_doc/wav2vec2.html) [介紹](https://huggingface.co/facebook/wav2vec2-large-xlsr-53)
&emsp;wav2vec2是一個自監督學習的語音框架，可以從語音音頻中學習，做為[聲學模型](https://zh.wikipedia.org/zh-tw/%E5%A3%B0%E5%AD%A6%E6%A8%A1%E5%9E%8B)。我們使用的是其中一款模型:wav2vec2-large-xlsr-53。
wav2vec2-kenlm:[Github](https://github.com/farisalasmary/wav2vec2-kenlm)
&emsp;wav2vec2-kenlm是一個使用[Beam Search](https://en.wikipedia.org/wiki/Beam_search)解碼音訊的語音辨識模型，結合KenLM語言模型以及wav2vec2CTC，提升辨識準確性，做為我們這次使用的[語言模型](https://zh.wikipedia.org/zh-tw/%E8%AA%9E%E8%A8%80%E6%A8%A1%E5%9E%8B)。




## 訓練資料

先來介紹一下我們所使用的所有資料

1. common voice 
    
    mozilla 提供一個免費共享多語言語料庫，我們使用到zh-TW(繁體中文，台灣腔)中的
    train 和 validation 資料集做為訓練資料。
    
    [官方網站](https://commonvoice.mozilla.org/zh-TW)

2. TCC300

    老師提供給我們的一包繁體中文台灣腔調語料庫。
    
3. Formosa Language Understanding Dataset (FLUD) Round1
    
    科技政策研究與資訊中心舉辦的科技大擂台比賽測試資料集，我們將第一輪的所有音訊文章切割，作為訓練資料。
    
    [資料來源](https://scidm.nchc.org.tw/dataset/grandchallenge)

4. 自行錄製
    
    將我們這組使用者可能會說的語句自行錄製，加以作為訓練資料。




## 研究過程

### 使用機器介紹

實驗室提供給我們的主機規格如下:

顯卡:NVIDIA RTX A4000 16GB
![](https://hackmd.io/_uploads/Hy_RVC5WT.png)

RAM:24GB
額外提供硬碟共2TB

### Step.0 架設docker

由於我們所需要使用的聲學模型(wav2vec2)需要依賴於[pyTorch](https://https://pytorch.org/)，
所以我們所選擇的docker image 為 [pytorch/pytorch](https://hub.docker.com/r/pytorch/pytorch)，
使用以下指令安裝並架設。
安裝:

    sudo docker pull pytorch/pytorch:latest
架設:

    sudo docker run --name 109trainer --gpus all --shm-size 64G -p 5555:8888 -p 5556:8889 -p 5557:8890 -itv /data2/project109/:/project109 -v /etc/timezone/:/etc/timezone:ro -v /etc/localtime/:/etc/localtime:ro  -v /data2/trainer-cache/:/root/.cache -v /data2/project109/workspace/:/workspace eb86f059e26c
    
我們一共設訂了三個 docker port 映射實體 port 分別為 8888 8889 8890 映射至 5555 5556 5557
此外，為了掛接實驗室提供的硬碟，我將 workspace 以及 huggingface datasets 這兩個會吃相當多硬碟資源的地方進行掛接。

接下來安裝jupyterlab-server方便大家同步開發。

#### **1. 先安裝依賴**

剛創好的docker為了防止apt安裝套件出現問題，一定要先使用以下指令確認apt版本最新或更新

    apt update&install

接著，安裝vim

    apt install curl vim git –y 

以上兩款都是必備套件，接下來開始安裝 jupyterlab-server所需

安裝node.js

    curl -L https://git.io/n-install | bash -s -- -y
    
安裝完成記得更新環境變數，一定要確定安裝完成的版本>=12。

    source ~/.bashrc 


接著就可以直接安裝本體

    conda install jupyterlab
    
#### **2. jupyterlab設定**

設定密碼

    jupyter notebook password 
會自動生成密碼文件

產生設定文件 

    jupyter lab --generate-config

修改設定文件使用 vim or nano 開啟剛剛產生的文件貼上以下內容。

    vim xxxxxxxxx(剛剛產生出的config)

將下面內容貼上後esc退出，接著 :wq

    c.NotebookApp.ip='*'
    c.NotebookApp.open_browser = False
    c.NotebookApp.port =8888
    c.NotebookApp.allow_remote_access = True

#### **3. jupyterlab啟動!!**

使用指令:

    jupyterLab server， jupyter-lab --allow-root

###  安裝所需套件

本次專題用到了以下套件
```
torch torchvision torchaudio # Pytorch cuda 12

librosa==0.7.2 # 讀取音檔

# 以下為wav2vec2相依
transformers==4.4.0
datasets==2.0.0
packaging==21.3
numba==0.48
resampy==0.3.1

# 以下為wav2vec2-kenlm相依
ctc-segmentation
ctcdecode
Levenshtein
jiwer

# 以下為展示平台及平台其他功能套件
fastapi
uvicorn 
jinja2
python-multipart
pydub
pymongo
rasa==3.5.6
pypinyin
jieba

```

### Step.1 模型訓練資料前處裡


#### 資料格式

首先我們需要先整理一下各音檔位址(path)及其音訊內容標籤(text)，將其歸納統整為1個json檔

資料格式很簡單，將**各音檔路徑"path"**，以及**對應句子"text"** 列出，寫一個list of dict就完成了。

```
# collect.json
[
{"path":"/workspace/dataset/pack1/1.wav","text":"今天天氣真好"},
{"path":"/workspace/dataset/pack1/2.wav","text":"今天天氣如何"},
...
]
```
#### **標籤(text)格式**

**注意:對應句子(標籤)不要出現不必要的空白或符號**
這邊我們的規則是:
1. 數字改成中文說法 ex:1000 -> 一千 250 -> 兩百五十
2. 如果是念英文縮寫一律大寫，念單詞則一律小寫，不在每個單詞間留空格。
    ex: WTO世界衛生組織表示...
        Java Scripts是一款... ->javascripts是一款...
    雖然對英文辨識度有改進空間，但我們暫且設計成這樣。
3. 若出現%，則依據音檔更改，如果音檔是說百分比，那就改成中文百分比 ex:50% -> 百分比五十
   反之，則留下 ex:50% ->五十%
   
**注意:common voice提供的資料可能會混雜一些符號或多於資訊，需再次過濾。**


#### json格式轉換

這次使用的模型hugging face提供給我們許多api，我們使用了hugging face的datasets來進行資料讀取。(以下簡稱datasets)

如果採用標準json格式，datasets會無法正常讀取報錯，解決方法也相當簡單，讀取collect.json後，將每筆資料逐行輸出，整理成datasets.json，如下所示。

```
# datasets.json
{"path":"/workspace/dataset/pack1/1.wav","text":"今天天氣真好"},
{"path":"/workspace/dataset/pack1/2.wav","text":"今天天氣如何"},
...
```

改成上面這種非list of dict，而是各dict一行就不會有問題了。

*Q:為何我們不在整理資料時就將json改成datasets支援的模式?*
A:主要是datasets需要的資料結構與標準json不太一樣，內建json套件載入datasets結構的json也會報錯，為了資料歸納整理方便，於是我們決定分兩步完成。


轉換程式可以參考[這裡(待補)]()

#### 建立vocab表

由於wav2vec2訓練需要我們建立一個vocab表，所謂vocab表就是一個文字對應編號的字典，舉例來說。

```
# vocab.json
{'今':0,'天':1,'氣':2,'真':3,'好':4,'如':5,'何':6,....'[PAD]':9998,'[UKN]':9999}
```
最後兩筆必須加入:[PAD]padding用符號及[UKN]表示未知字元用符號。

讀取整理好的datasets.json，利用set的不重複特性，先彙整整個datasets出現的文字，最後再依序給值就能做出來。

轉換程式可以參考[這裡(待補)]()

**注意:datasets讀取檔案是將內容全部放入train子集裡(未設定的話)**
結構如下
```
{
    'train':[
        {"path":"/workspace/dataset/pack1/1.wav","text":"今天天氣真好"},
        {"path":"/workspace/dataset/pack1/2.wav","text":"今天天氣如何"},
        ...
    ]
}
```

#### 切割測試集和訓練集

接下來我們要將準備好的資料切割成測試集以及訓練集，好在datasets提供給我們很多api讓我們自由分割或合併測試集，以下整理datasets提供的讀取、輸出、合併、分割api

```
# 讀取本機檔案
datasets.load_dataset({檔案類型 如:json},data_files={檔案路徑})
# 讀取hugging face上提供的資料集 注:有些會需要註冊申請token
datasets.load_dataset({資料集名稱},{子資料集名稱},split={類別},use_auth_token={token})
# 合併A B 兩資料集，A B資料集必須結構相同
# A B必須為DatasetDict
datasets.concatenate_datasets([A,B,...])
# 切割 將A資料集分割 
A.train_test_split(test_size={測試資料集佔A比例 如:0.2 將20%y資料傳入test子集},shuffle={洗牌 bool},seed={洗牌 隨機數})
# 輸出為hugging face格式的json
datasets.to_json({輸出檔案名稱及路徑}, force_ascii=False,orient='records')
# force_ascii=False,orient='records' 是為了使中文輸出正常
```
此外datasets還提供許多api可以[參考這裡](https://huggingface.co/docs/datasets/index)

我們將訓練集設定成:TCC300+common voice{train,validation}+FLUD+自行補充訓練資料
測試集設定為:自行補充測試資料+common voice{test}

### Step.2 聲學模型訓練

[參考資料](https://huggingface.co/docs/transformers/v4.31.0/en/tasks/asr)

#### 讀取音檔，取得特徵

利用librosa套件，將所有音檔取出特徵。
我們把取樣頻率sample rate設為16000。
```
def readSpeech(data):

    # 讀檔 sound為特徵值 rate為取樣頻率16000
    sound,rate=lib.load(data['path'],sr=16000)
    
    # 將data資料整理
    data['speech']=sound # 特徵
    data['rate']=rate # 取樣頻率
    data['content']=data['text'] # 標籤
    del data['path']
    del data['text']
    return data
```
經過處理，資料應該變成:
```
{"speech":特徵A,"rate":"16000","content":"今天天氣真好"},
{"speech":特徵B,"rate":"16000","content":"今天天氣如何"},
...
```
#### 加載transformer處理器

第一個要加載的套件為transformers中的Wav2Vec2CTCTokenizer分詞器。
```
tokenizer = Wav2Vec2CTCTokenizer(
            'vocab.json',
            unk_token='[UNK]',
            pad_token='[PAD]', 
            word_delimiter_token='|'
)
```
將整理好的vocab.json餵入，設定特殊token如:padding用token[PAD]、未知token[UNK]。
[分詞器參考資料](https://huggingface.co/transformers/v4.9.2/model_doc/wav2vec2.html#wav2vec2ctctokenizer)

第二個要加載的套件為Wav2Vec2FeatureExtractor特徵提取器。
```
featureExtractor=Wav2Vec2FeatureExtractor(
                feature_size=1,
                sampling_rate=16000,
                padding_value=0.0,
                do_normalize=True,
                return_attention_mask=False
)
```
設定輸出特徵維度為1，取樣頻率為16000，padding值設為0.0且正規化輸出。
[特徵提取器參考資料](https://huggingface.co/transformers/v4.9.2/model_doc/wav2vec2.html#wav2vec2featureextractor)

最後將特徵提取器及分詞器放入Wav2Vec2Processor處理器。

```
processor = Wav2Vec2Processor(feature_extractor=featureExtractor, tokenizer=tokenizer)
```

之後利用處理器對各筆資料做處理(標籤文字編碼及特徵處理)。
```
def processorPrepare(data): 
    data['input_values']=processor(data['speech'],sampling_rate=data['rate'][0]).input_values
    with processor.as_target_processor():
        data['labels']=processor(data['content']).input_ids
    return data
```
根據上面程式碼，input_values為特徵，labels為標籤。
[處理器參考資料](https://huggingface.co/transformers/v4.9.2/model_doc/wav2vec2.html#wav2vec2processor)

最後，利用處理器設計DataCollector，目的為padding各筆資料。

```
@dataclass
class DataCollatorCTCWithPadding:
    processor: Wav2Vec2Processor
    padding: Union[bool, str] = True
    max_length: Optional[int] = None
    max_length_labels: Optional[int] = None
    pad_to_multiple_of: Optional[int] = None
    pad_to_multiple_of_labels: Optional[int] = None

    def __call__(self, features: List[Dict[str, Union[List[int], torch.Tensor]]]) -> Dict[str, torch.Tensor]:
        #把 input(音訊特徵)及label(對應文字)分開來各自padding 因為長度不同
        input_features = [{"input_values": feature["input_values"]} for feature in features]
        label_features = [{"input_ids": feature["labels"]} for feature in features]
        
        batch = self.processor.pad(
            input_features,
            padding=self.padding,
            max_length=self.max_length,
            pad_to_multiple_of=self.pad_to_multiple_of,
            return_tensors="pt",
        )
        with self.processor.as_target_processor():
            labels_batch = self.processor.pad(
                label_features,
                padding=self.padding,
                max_length=self.max_length_labels,
                pad_to_multiple_of=self.pad_to_multiple_of_labels,
                return_tensors="pt",
            )
        # 將padding值以-100取代，減少損失
        labels = labels_batch["input_ids"].masked_fill(labels_batch.attention_mask.ne(1), -100)
        batch["labels"] = labels
        return batch
```
[參考資料](https://huggingface.co/docs/transformers/v4.31.0/en/tasks/asr)

#### 加載評估指標

由於我們的目標語言為中文，中文的最小且有辨識度的單位為"字"。我們要測試預測結果與實際結果的最簡單方法為計算 **單字錯誤率(word error rate, wer)**。

wer的計算方式如下:
![image](https://hackmd.io/_uploads/BJ0gWnwNp.png)
S為錯誤字數(替換字數)，D為刪除字數，I為多餘字數而N為實際字數。
舉例來說: 
```
    正確答案>今天天氣真好
    預測結果>今天氣真豪
    S=1 D=1 I=0 N=6
    wer=33.33%
```
我們可以利用datasets提供的api將wer演算法加載。
```
wer_metric = datasets.load_metric("wer")
def compute_metrics(pred):
    pred_logits = pred.predictions 
    pred_ids = np.argmax(pred_logits, axis=-1)
    pred.label_ids[pred.label_ids == -100] = processor.tokenizer.pad_token_id # 將預測出來的padding 值改成[PAD]的token id
    pred_str = processor.batch_decode(pred_ids) # 將預測結果token id改回中文字
    label_str = processor.batch_decode(pred.label_ids, group_tokens=False) # 將實際答案token id改回中文字
    wer = wer_metric.compute(predictions=pred_str, references=label_str)
    return {"wer": wer}
```
#### 加載模型

訓練前處理到這邊才算是完全結束。接下來我們開始加載預訓練模型。
```
model=Wav2Vec2ForCTC.from_pretrained(
    "facebook/wav2vec2-large-xlsr-53", 
    attention_dropout=0.1,
    hidden_dropout=0.1,
    feat_proj_dropout=0.0,
    mask_time_prob=0.05,
    layerdrop=0.1,
    gradient_checkpointing=True,
    ctc_loss_reduction="mean", 
    pad_token_id=processor.tokenizer.pad_token_id,
    vocab_size=len(processor.tokenizer)
)
```
我們加載由facebook提供的預訓練模型wav2vec2-large-xlsr-53，
它支援53種語言(包含中文)且預訓練資料使用取樣頻率也是16000，非常適合我們的專題。

[模型來源](https://huggingface.co/facebook/wav2vec2-large-xlsr-53)

接下來我們來利用我們的資料將預訓練模型微調(**fine tune**)
先來調整訓練參數
```
training_args = TrainingArguments(
  num_train_epochs=100, #訓練次數 
  group_by_length=True,
  fp16=True,
  prediction_loss_only=True, # 必設True False會報錯
  per_device_train_batch_size=2, # 訓練批次大小
  per_device_eval_batch_size=2, # 驗證批次大小
  dataloader_num_workers=2,
  learning_rate=3e-4,
  gradient_accumulation_steps=16,# 補償步數
  warmup_steps=500,
  output_dir=os.path.join(SAVEPATH,'model'),
  evaluation_strategy="steps",
  eval_steps=6010, # 每X步valid一次 
  save_strategy="steps",
  save_steps=12020,# 每X步存一次模型 
  logging_dir=os.path.join(SAVEPATH,'model','run'),
  logging_strategy="steps",
  logging_steps=6010, # 每X步log一次結果
  load_best_model_at_end=True
)
```
我們設定num_train_epochs次數為100。learning rate設為3e-4。
由於RAM不足的原因我們必須將 prediction_loss_only設為True，缺點是我們無法得知每次valid的wer為多少，只能得知loss。

由於RAM大小問題我們一次訓練資料batch為2，為了避免小batch頻繁更新模型參數，造成結果不佳，我們要使用梯度累加(gradient accumulation)，將多batch結果累積再更新，達到大batch的結果，
使訓練效果更佳。

總步數(steps)的算法為=(總資料數/tain_batch_size(無條件取整)/(gradient accumulation_steps(無條件取整))*訓練次數(num_train_epochs)。
```
舉例來說
dataset100筆 每2筆1batch 每4batch更新一次 訓練20次
steps=int(int(100/2)/4)*20
=260
```

我們設定每6010步驗證一次，12020步輸出一次模型。

接下來，我們就可以把所有準備好的東西打包入trainer訓練器，開始訓練。

```
from transformers import Trainer
trainer = Trainer(
    model=model,
    data_collator=data_collator, # padding器
    args=training_args, #訓練資料
    compute_metrics=compute_metrics, # 方式(wer)
    train_dataset=trainDatas['train'], # 訓練資料
    eval_dataset=evalDatas['train'], # 驗證(測試)資料
    tokenizer=processor.feature_extractor,# 分詞器
)
trainer.train() #開始訓練
```

### Step.3 語言模型訓練

聲學模型開始訓練後，我們要訓練一個匹配的語言模型來輔佐預測。因為聲學模型基本上就是把聽到的特徵根據學習參數來找到最有可能的答案。
但是，中文常出現一音多字的情況，就像你聽到他人的名字，如果對方不告訴你名字分別為那些字，你無法完全確認他的名字中的字是哪些。
對於我們專題中，店名的辨識會是非常重要的一環，避免之後自然語言分析上出現問題，我們必需訓練一個語言模型來做店名矯正的功能。

我們選擇了wav2vec2-kenlm做我們語言模型，它是一款利用Beam Search來解碼音訊的語音辨識模型，結合KenLM語言模型以及wav2vec2CTC，提升辨識準確性。

#### 相依套件安裝
首先，根據[Github上的教學](https://github.com/farisalasmary/wav2vec2-kenlm)將相依套件安裝

```
git clone --recursive https://github.com/parlance/ctcdecode.git
cd ctcdecode && pip install .
pip install ctc-segmentation
```
#### 準備訓練資料

訓練資料準備要注意**字和字之間需要以單空白來隔開**
將所有標籤列出，轉存成txt檔
```
#datasets.txt
今 天 天 氣 真 好
今 天 天 氣 如 何
...
```

#### 開始訓練

執行指令
```
bin/lmplz -o 3 --verbose_header --text datasets.txt --arpa output.arpa
```
我們設定該模型統計並訓練，[3-gram](https://ithelp.ithome.com.tw/articles/10266467)的資料，在text參數放入準備好的訓練資料，輸出模型設定為output.arpa，就可以開始訓練。


### Step.4 評估效果

以上兩個模型都訓練完成，我們就可以著手進行評估模型了。
評估指標仍採用wer(單字錯誤率)。

#### 載入模型

先把訓練好的wav2vec2聲學模型載入。
```
device = 'cuda' if torch.cuda.is_available() else 'cpu'
processor = Wav2Vec2Processor.from_pretrained({processor路徑}) # 載入processor
model = Wav2Vec2ForCTC.from_pretrained({model路徑}) # 載入model 
model.to(device) 
```
藉由processor，我們要把vocab提出供語言模型使用。
```
sortDict=sorted((value, key) for (key,value) in vocabDict.items()) #將文字對應編號改成編號對應文字並排序
vocab=[]
for _,token in sortDict:
    vocab.append(token)

```
依據上面程式碼，我們會把vocab會變成:
```
['今','天','氣','真','好','如','何',....'[PAD]','[UKN]']
```
我們不須採用dict的格式是因為我們已經把編號都排序完成了。

接著，載入wav2vec2-kenlm語言模型
```
def init_kenlm(alpha=0.9, beta=0.9, beam_width=32): 
    beam_decoder = BeamCTCDecoder(vocab[:-2], lm_path=langModel,
                alpha=alpha, beta=beta,
                cutoff_top_n=40, cutoff_prob=1.0,
                beam_width=beam_width, num_processes=16,
                blank_index=vocab.index(processor.tokenizer.pad_token))
    return beam_decoder
beam_decoder = init_kenlm()
```
由於語言模型vocab表不須後面兩個特殊符號(['PAD']和['UNK'])，所以我們只需要vocab[:-2]，
但我們依舊需要把padding符號告知，其他參數基本上不需要調整。

#### 開始預測
```
def predict(data):
    speech,rate= librosa.load(data['path'],sr =16000,mono=True) # 讀檔
    input_values = processor(speech, sampling_rate=rate, return_tensors="pt").input_values.to(device) #取特徵
    with torch.no_grad():
        logits = model(input_values).logits # 放入模型input layer及運算
    # 以下為聲學模型預測
    pred_ids = torch.argmax(logits, dim=-1) 
    pred_str = processor.batch_decode(pred_ids)[0] # 回傳各音框最高可能性單詞
    data['predictAM']=pred_str
    # 以下為語言模型預測
    beam, beam_decoded_offsets = beam_decoder.decode(logits)
    data['predictLM']=beam[0][0]
    return data
```
根據以上程式碼可以獲得每筆資料，純聲學模型結果'predictAM'以及聲學和語言模型結果'predictLM'

```
[
    {'path':'sample1.wav','text':'明天會下雨嗎','predictAM':'明天會夏雨嗎'predictLM':'明天會下雨嗎'}
    ...
]
```

#### 評估

利用datasets提供的api載入wer演算法，進行計算
```
wer=load_metric("wer") 
AMScore=wer.compute(predictions=datas['predict'],references=datas['text'])
LMScore=wer.compute(predictions=datas['lm'],references=datas['text'])
```
AMScore為聲學模型wer
LMScore為語言模型wer

## 作品呈現

我的作品呈現平台採用**網頁**，使用的後端網頁框架為FastApi，利用FastAPI和組員們負責的RASA自然語言處理以及mongoDB資料庫進行聯絡。

選用FastAPI的原因是:標榜速度快、支援多工並**自動生成api文件**。

### 前端渲染

前端部分採用HTML+JavaScript，利用FastAPi提供的TemplateResponse加上Jinja2來呼叫個個HTML模板做渲染。
```
@app.get('/') #設定路由
    async def home(request:Request):
        id = str(uuid.uuid4()) # 生成一個uuid
        return templates.TemplateResponse('home.html',{'request':request,'uuid':id}) # 渲染 home.html
```
上面這個就是最簡單的例子，利用TemplateResponse渲染home.html。
uuid用在我們聊天室和資料傳輸檔名使用，發生uuid重複的機率很低。

### 即時錄音

為了方便使用者進行即時錄音，使用了[MediaStream API](https://developer.mozilla.org/en-US/docs/Web/API/MediaStream)來獲得使用者麥克風的權限及錄音。以下為程式碼

```
const recordBtn = document.getElementById("record-button"); // 錄音按鈕
const input = document.getElementById("file"); // 上傳檔案input

let chunks= [];

if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
    alert("您的瀏覽器不支援錄音");
    recordBtn.disabled=true; // 錄音按鈕不可用
}
else{
    if (MediaRecorder.isTypeSupported("audio/webm;codecs=opus")){
        extension="webm";  適用於Chrome
    }
    else{
        extension="ogg"; // 適用於FireFox
    }
    navigator.mediaDevices.getUserMedia({
        audio: true, // 只須獲得使用者麥克風權限
    })
    .then(stream =>{
        // 成功獲得麥克風權限
        const recorder = new MediaRecorder(stream,{mimeType:"audio/"+extension+";codecs=opus"});
        recordBtn.addEventListener("click",()=>{
            if (recorder.state === "recording"){
                // 如果正在錄音
                recorder.stop(); // 停止錄音
                recordBtn.value = "語音輸入"; 
            }
            else{
                // 如果尚未開始錄音
                recorder.start();
                recordBtn.value = "結束錄音";
            }
        });
        recorder.ondataavailable = datas=>{
            chunks.push(datas.data);
            if (recorder.state == "inactive") {
                // 錄音結束會進入 inactive
                const blob = new Blob(chunks, {type : "audio/"+extension});
                const file = new File([blob],"record."+extension,{type:"audio/"+extension});
                const datas = new DataTransfer();
                datas.items.add(file);
                input.files=datas.files; // 將檔案(webm,ogg)放入上傳資料
                chunks = [];
            }
        };
    },
    ()=>{
        // 未獲得麥克風權限
        alert("您未開放麥克風權限QQ");
        recordBtn.disabled = true;
    });
}
```

**Q:明明還沒問我權限，就顯示不支援，發生了什麼事?**
A:由於Chrome瀏覽器不開放未使用https協定(不安全)網域詢問權限，所以錄音功能被禁用。
**解決方法:到 chrome://flags/#unsafely-treat-insecure-origin-as-secure 更改設定**，將我們的網域視為安全來源。


### api設計

我們設計了一些api
* sendAudio 用於接收檔案並轉檔
* result 測試平台回覆表單
* delete 測試平台用於刪除使用者音檔
* report 測試平台用於儲存使用者音檔資訊
* predict 聊天室用於回傳辨識結果

還有一些溝通用api

* audio 預覽使用者錄音結果用，回傳音檔資料
* pic 聯絡google api 回傳圖片，保護api key

接下來將介紹我們所設計的api

**總網頁功能圖**

![總功能圖](https://hackmd.io/_uploads/rJe_XlFNT.png)


**測試平台流程**

![測試平台流程.drawio](https://hackmd.io/_uploads/ryNfexYN6.png)


**聊天室流程**

![聊天室流程圖](https://hackmd.io/_uploads/B1CV6JtNT.png)


#### sendAudio

**由於librosa不支援webm及ogg格式**，我們必須使用[pydub](https://github.com/jiaaro/pydub/)套件來轉換格式。

```
if format=="audio/webm":
    #webm轉換
    audio=AudioSegment.from_file(path,format="webm ) # 讀取webm
    os.remove(path) # 刪除原檔
    audio.export(os.path.splitext(path)[0]+".wav",format="wav") # 轉檔為wav
```
以上程式碼示範如何將webm轉為wav，ogg的轉檔也大同小異。

此api採用**POST**方法，接收前端傳來檔案以及檔名(uuid)，若成功將會回傳檔案名稱(uuid)，失敗則回傳error並報status code 400。此api用於測試平台以及聊天室。

**為了避免使用者不小心更改uuid，我們將uuid input hidden且readonly**

接下來三個api皆為測試平台調用。

#### result

此api用於測試平台渲染回饋表單，畫面上會呈現給使用者的錄音結果以及模型的辨識結果。

使用**GET**方法，使用Path parameter傳檔案名稱(uuid)，會展示其結果。
```
@app.get("/result/{id}",response_class=HTMLResponse)
async def result(request:Request,id:str):
    # 測試結果及表單
    response = core.api(TEMPDIR+id+".wav") # 呼叫辨識模型辨識音檔
    audioURL= os.path.split(response["path"])[1].strip() # 使用者提供的音檔
    return templates.TemplateResponse("result.html",{"request":request ,"response":response,"audio":audioURL}) # 渲染結果及表單
```

根據表單資料使用者可以選擇是否願意提供音檔做為訓練資料。

#### delete (使用者拒絕提供)

此api使用**POST**方法，接受表單中檔案名稱(uuid)，並將其刪除。
```
@app.post("/delete")
async def delete(path:str=Form(...)):
    # 使用者拒絕提供音檔 刪除
    try:
        os.remove(path) # 刪除音檔
        return Response(content="success",status_code=200)
    except:
        return Response(content="error",status_code=400)
```

#### report (使用者允許提供)

若使用者允許，我們將會把使用者提供的音檔以及標籤，放入收集用json中，方便之後再次訓練。
```
    with open("collector/collect.json","r",encoding="utf-8")as f:
        datas=json.load(f) # 開啟json
    data={"path":path,"label":label} # 將接受到的資訊建立成一筆dict
    datas.append(data) # 添加入json
    with open("collector/collect.json","w",encoding="utf-8")as f:
        json.dump(datas, f,ensure_ascii=False) # 存檔
    return
```
此api使用**POST**方法，接受表單中檔案路徑(path)和標籤(label)，並將其保存。

為了避免使用者更改path導致檔案無法對上，也將該表單input設定為hidden且readonly。

#### predict 

此api給聊天室語音輸入使用，其功能結合了模型預測結果回傳(result)以及刪除檔案(delete)

```
@app.post('/predict')
async def predict(id:str=Form(...)):
    # 給聊天室語音輸入用 預設刪除使用者音檔
    result = core.api(TEMPDIR+id+".wav") # 讀取音檔並餵給模型預測
    os.remove(TEMPDIR+id+".wav") # 刪除音檔
    return JSONResponse(result,status_code=200) # 回傳結果
```

此api使用**POST**方法，接受表單中uuid(檔案名稱)，並回傳預測資料，且為了保護使用者資料，處理完成後將暫存音檔刪除。

### 成果圖片
**聊天室**
![image](https://hackmd.io/_uploads/BJWEVgFNp.png)

**DB資料展示**
![image](https://hackmd.io/_uploads/S1zGNeYE6.png)

**測試平台表單**
![image](https://hackmd.io/_uploads/H1AsPkKEp.png)

**自動生成api文件**
![image](https://hackmd.io/_uploads/HJbeOJKNT.png)
