【Flet】AmiVoice APIを使って「英語のハノン」アプリを作る

# はじめに
冒頭から私事で恐縮ですが、私は英語学習教材の一つとして「[英語のハノン](https://www.chikumashobo.co.jp/special/Eigo_no_Hanon/)」を愛用しています。
英語のハノンで繰り返し練習することで、英語スピーキングテストアプリ「[PROGOS](https://progos.ai/)」の「正確さ(Accuracy)」項目において当初「A2」レベルだった私が → 「B2」またはそれ以上をコンスタントにとれるまでになりました。
このような実体験としての効果から、私にとって英語のハノンは手放せない存在です。
（もちろんPROGOSにも感謝です！）

いつもはスマホの音楽再生アプリでやっているのですが、次項の「やりたいこと」がずっと気になっていました。

そんな折、株式会社アドバンスト・メディア様の「AmiVoice」の記事投稿キャンペーンが目に留まりました。
AmiVoiceは音声認識結果のテキストに加えて、単語単位で「[信頼度](https://docs.amivoice.com/amivoice-api/manual/user-guide/response/result-format#%E7%B5%90%E6%9E%9C%E3%81%AE%E8%A6%81%E7%B4%A0)」も返してくれるようです。
「これはやりたいことができそうだ！」ということで、やってみることにしました。

# やりたいこと
- 「英語のハノン」をモバイルアプリ化する
    - ★自分の発話/発音の正確性を確認したい
    - 設問や正解音声が何を言っているのかテキストで確認したい（開本モード/閉本モード）
    - 閉本で完璧にできるようになったUnitはスキップできるようにしたい
    - 毎日やっているか確認したい
    - ChatGPTを使用して同じ構文で異なる単語を使って飽きないようにしたい
- Pythonの勉強を兼ねる

今回はとりあえず★印のみの実現が目標です。
Pythonは始めたばかりですが、勉強を兼ねたいのでフレームワークとして「Flet」を使ってみることにしました。

# 開発環境
Windows10
Python3.11.9
PyCharm Community Edition

# 結論
思い立ってからの時間が少なく、最後までできませんでした。。。
とりあえずキャンペーンの締切なのでやったところまで公開します。
あと一歩のところまで来たとは思いますので、必ず完成させて全コードを公開したいと思います。
ご興味がおありの方は経過を見守っていただけますと幸いです。

# やったこと
### 0. AmiVoide APPKEYの取得
「[音声認識APIを使ってみよう！](https://qiita.com/official-events/23333339b694ca71810a)」キャンペーンの手順通り、AmiVoideの `APPKEY`の取得を申し込みます。
`APPKEY`はマイページの「接続情報」で確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/08917dbb-31d9-6f50-885c-5c03f8b4aa51.png)

### 1. AmiVoiceのPythonサンプルの動作確認
[AmiVoice API クライアントライブラリ](https://github.com/advanced-media-inc/amivoice-api-client-library) から一式ダウンロードします。
今回のケースでは長くても数秒の短い音声を認識させたいため、「HTTP音声認識API (`Hrp`)」を使えば良いようです。
したがって`Hrp`フォルダ以下の`python`フォルダ以下のサンプルをベースにすれば良さそうです。
注意事項として

:::note warn
Windows プラットフォームでライブラリを使用する際、 サーバ証明書データベースファイルへのパスを環境変数 SSL_CERT_FILE に指定する必要があります。
:::
とあるので、サンプル一式の直下にある`curl-ca-bundle.crt`ファイルにパスを通します。

パスを通したら、Hrp\python以下の`run.bat`を実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/e0f05cff-54ca-61a9-8140-30619cecd445.png)
上記0項で取得した`APPKEY`をペーストし`Enter`すると・・・

エラーになってしまいました。。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/f48e9cc1-845b-df81-4926-82eff1b1e1a6.png)
赤線のエラーメッセージを検索すると、Python3.12特有のエラーのようでした。

Python3.11.9をインストールし、Pathを通します。
`C:\Users\$user\AppData\Local\Programs\Python\Python311\`

再度`run.bat`を実行します。
今度は成功したようです👍
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/a031d002-854a-d46e-c8d0-513f0486fb1d.png)


### 2. PythonコードからAmiVoide APIを実行する
サンプルコードでは認識結果をprintするだけになっているため、コード（アプリ）から使いやすいように改造します。
サンプルの`HrpSimpleTester.py`の名称を`AmiVoice_Hrp.py`に変更し、次のようにしました。
（`get_result`は無理に実装した感じです。良い方法がわかる方、ご教示ください！）

```python:AmiVoice_Hrp.py
# encoding: UTF-8
import sys
import time
import json
# <!-- バイトコードキャッシュファイルの作成を抑制するために...
sys.dont_write_bytecode = True
# -->
import com.amivoice.hrp.Hrp
import com.amivoice.hrp.HrpListener

class HrpSimple_Sync(com.amivoice.hrp.HrpListener):

	def __init__(self, appkey):
		self.serverURL = "https://acp-api.amivoice.com/v1/recognize"
		self.codec = "16K"
		self.authorization = appkey
		# HTTP 音声認識サーバイベントリスナの作成
		self.listener = self
		self.result = None

	def run_stt(self, audioFileName, grammarFileNames):
		# HTTP 音声認識サーバの初期化
		hrp = com.amivoice.hrp.Hrp.construct()
		hrp.setListener(self.listener)
		hrp.setServerURL(self.serverURL)
		hrp.setCodec(self.codec)
		hrp.setGrammarFileNames(grammarFileNames)
		hrp.setAuthorization(self.authorization)

		# HTTP 音声認識サーバへの接続
		if not hrp.connect():
			print(hrp.getLastMessage())
			print(u"HTTP 音声認識サーバ %s への接続に失敗しました。" % self.serverURL)
			return

		try:
			# HTTP 音声認識サーバへの音声データの送信の開始
			if not hrp.feedDataResume():
				print(hrp.getLastMessage())
				print(u"HTTP 音声認識サーバへの音声データの送信の開始に失敗しました。")
				return

			try:
				with open(audioFileName, "rb") as audioStream:
					# 音声データファイルからの音声データの読み込み
					audioData = audioStream.read(4096)
					while len(audioData) > 0:
						# 微小時間のスリープ
						hrp.sleep(1)

						# HTTP 音声認識サーバへの音声データの送信
						if not hrp.feedData(audioData, 0, len(audioData)):
							print(hrp.getLastMessage())
							print(u"HTTP 音声認識サーバへの音声データの送信に失敗しました。")
							break

						# 音声データファイルからの音声データの読み込み
						audioData = audioStream.read(4096)
			except:
				print(u"音声データファイル %s の読み込みに失敗しました。" % audioFileName)

			# HTTP 音声認識サーバへの音声データの送信の完了
			if not hrp.feedDataPause():
				print(hrp.getLastMessage())
				print(u"HTTP 音声認識サーバへの音声データの送信の完了に失敗しました。")
				return
		finally:
			# HTTP 音声認識サーバからの切断
			hrp.disconnect()

	def resultCreated(self, sessionId):
		# print("C %s" % sessionId)
		pass

	def resultUpdated(self, result):
		# print("U %s" % result)
		pass

	def resultFinalized(self, result):
		self.result = result
		# print("F %s" % result)
		print(result)
		text = self.text_(result)
		if text != None:
			print(" -> %s" % text)

	def TRACE(self, message):
		pass

	def text_(self, result):
		try:
			return json.loads(result)["text"]
		except:
			return None
    
    # 無理やり…
	def get_result(self):
		while True:
			if self.result != None:
				return self.result
			time.sleep(0.1)

```

上記に合わせてmain.pyを次のようにしました。
英語を認識させるため、会話エンジンをサンプルの`-a-general`から`-a-general-en`に変更します。
```python:main.py
from AmiVoice_Hrp import HrpSimple_Sync

if __name__ == "__main__":
    hrp = HrpSimple_Sync("MY_APPKEY")
    hrp.run_stt(r".\hanon2\unit1\chunk0.mp3", "-a-general-en")
    print(hrp.get_result())
```

mainを実行すると、イイ感じで認識しているようです。

```
{"results":[{"tokens":[{"written":"I","confidence":1.00,"starttime":530,"endtime":690,"spoken":"I"},{"written":"happen","confidence":0.71,"starttime":690,"endtime":1010,"spoken":"happen"},{"written":"to","confidence":0.98,"starttime":1010,"endtime":1090,"spoken":"to"},{"written":"see","confidence":1.00,"starttime":1090,"endtime":1370,"spoken":"see"},{"written":"her","confidence":1.00,"starttime":1370,"endtime":1730,"spoken":"her"},{"written":"I","confidence":1.00,"starttime":2090,"endtime":2250,"spoken":"I"},{"written":"was","confidence":0.98,"starttime":2250,"endtime":2430,"spoken":"was"},{"written":"visiting","confidence":1.00,"starttime":2430,"endtime":2810,"spoken":"visiting"},{"written":"the","confidence":1.00,"starttime":2810,"endtime":2890,"spoken":"the"},{"written":"museum","confidence":0.92,"starttime":2890,"endtime":3690,"spoken":"museum"},{"written":"."}],"confidence":0.996,"starttime":250,"endtime":3690,"tags":[],"rulename":"","text":"I happen to see her I was visiting the museum."}],"utteranceid":"20240521/21/018f9b2a90b60a303f3b94c9_20240521_214009","text":"I happen to see her I was visiting the museum.","code":"","message":""}
```

### 3. 英語のハノンのMP3音声を無音部分で分割する
まず、英語のハノンサイトに公開されている音声教材ファイルをダウンロードします。
今回は実験的に [中級](https://www.chikumashobo.co.jp/special/Eigo_no_Hanon/2/#download) のUnit1のみを使用します。
中級のUnit1のMP3ファイルを次のようにダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/2ff2fe76-6b46-d071-030b-53e3c9b3c3ef.png)


英語のハノンでは、構文練習単位として１ユニット５項目構成となっており、次のような音声構成になっています。
```:英語のハノン音声構成
原文音声1
（発話）
原文音声1(リピート)
（発話）
キュー
（発話）
回答音声1
（発話）
回答音声1(リピート)
（若干の間）
原文音声2
（発話）
原文音声2(リピート)
（発話）
…
回答音声5(リピート)
（発話）
```
「（発話）」の部分は原文や回答音声の長さに応じた無音部分となっており、この無音部分の時間を使って学習者が発話します。
アプリでは以下の手順：
1. 音声再生
1. 音声認識
1. 結果表示

を繰り返したいので、「音声再生」部分は項目ごとに分割する必要があります。
したがって、上記でダウンロードした中級Unit1のMP3ファイル「unit1_1.mp3」を無音部分で分割します。
無音部分で音声を分割するツールにはいろいろあるようですが、今回は以下を使用しました。
https://github.com/danesjenovdan/audio-splitter
(詳細は後日記載)

### 4. Fletのスケルトンを作成
公式の [Audio](https://flet.dev/docs/controls/audio/) および [AudioRecorder](https://flet.dev/docs/controls/audiorecorder) のサンプルをベースに、main.pyを書き換えて簡単な画面を作成します。
（画面）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3794426/22c53f8b-b40f-a515-050a-ab2ea161a056.png)
```main.py
import flet as ft
import os
import time
from AmiVoice_Hrp import HrpSimple_Sync

async def main(page: ft.Page):
    page.horizontal_alignment = ft.CrossAxisAlignment.CENTER
    page.appbar = ft.AppBar(title=ft.Text("英語のハノン 中級 Unit1"), center_title=True)
    rec_wav_path = "stt_result.wav"

    async def handle_start_recording(e):
        print(f"StartRecording: {rec_wav_path}")
        await audio_rec.start_recording_async(rec_wav_path)
        await toggle_Record_Button(True)

    async def handle_stop_recording(e):
        output_path = await audio_rec.stop_recording_async()
        print(f"StopRecording: {output_path}")
        if page.web and output_path is not None:
            await page.launch_url_async(output_path)
        await toggle_Record_Button(False)

    async def toggle_Record_Button(rec_start):
        Btn_start_Recording.visible = not rec_start
        Btn_stop_Recording.visible = rec_start
        await page.update_async()

    # Unit1.1のファイルをリストアップ
    ftAudio_chunks = []
    for chunk in os.listdir(r".\hanon2\unit1"):
        ftAudio_chunks.append(
            ft.Audio(
                src=os.path.join(r".\hanon2\unit1", chunk),
                autoplay=False,
                volume=1,
                balance=0,
            )
        )

    def play_audio_files(e):
        # 今回の主要部分！
        pass

    Btn_Unit1 = ft.ElevatedButton("Unit1.1",
                                  icon=ft.icons.PLAY_ARROW,
                                  #on_click=lambda _: audio1.play())
                                  on_click=play_audio_files)

    async def handle_state_change(e):
        print(f"State Changed: {e.data}")

    audio_rec = ft.AudioRecorder(
        audio_encoder=ft.AudioEncoder.WAV,
        on_state_changed=handle_state_change,
    )

    page.overlay.append(audio_rec)
    for ft_chunk in ftAudio_chunks:
        page.overlay.append(ft_chunk)
    page.update()

    Btn_start_Recording = ft.IconButton(
            icon=ft.icons.MIC,
            icon_color="blue400",
            icon_size=40,
            tooltip="Start record",
            on_click=handle_start_recording,
        )
    Btn_stop_Recording = ft.IconButton(
            icon=ft.icons.STOP_CIRCLE,
            icon_color="pink600",
            icon_size=40,
            tooltip="Stop record",
            visible=False,
            on_click=handle_stop_recording,
        )

    page.add(
        Btn_Unit1,
        Btn_start_Recording,
        Btn_stop_Recording,
    )

ft.app(target=main)
```

### 5. MP3再生→音声認識→結果表示を一気通貫させる
ここまでお読みいただいた方、申し訳ございません！
時間切れでできませんでした😇
進展があり出来次第本記事を修正していきたいと思います。

# 謝辞
このたびキャンペーンで無料APIを提供してくださった株式会社アドバンスト・メディア様に感謝申し上げます。
おかげさまで長年やりたかったことをやるきっかけになりました。