松屋のUIが叩かれがちなので、ChatGPT APIを使ったVUI注文システムを作ってみる

## はじめに

　下記の記事のように何かと叩かれがちな松屋のタッチパネルUI、ただ個人的には松屋以外の居酒屋、ファミレス、回転寿司などメニューのジャンル、種類が多いお店に関しては、タッチパネルよりも口頭注文の方が簡単で注文しやすいと思ってます。　
　（※余談ですが、居酒屋のタッチパネルで注文する際、枝豆のジャンルが定番だったり前菜だったり即菜だったりするの統一してほしい。）

https://biz-journal.jp/2023/05/post_340501.html

　ということで、ChatGPT APIを使ってVUI(Voice User Interface)注文システムをサクッと作ってみます。

## 使用する技術、フレームワークなど
Python
Flask
ChatGPT API
Whisper_mic

　必要なものは適宜インストール、Whisper_micに関しては以下のWeb記事を参照して下さい。(2023/06/24時点ではWhisper_micの実装コードがmic.pyからcli.pyになっているようです。)

https://www.kkaneko.jp/ai/repot/micrecog.html

## ディレクトリ構成
　実行コードのディレクトリにWhisper_micをgitでクローンしてください。ディレクトリ構成は以下になります。

```
matsuya_order_app
 ├─history (※音声変換テキスト置き場用ディレクトリ)
 ├─templates
 │  └─  index.html
 ├─whisper_mic
 │  ├─  cli.py
 │  ├─  LICENSE
 │  ├─  mic.py
 │  ├─  pyproject.toml
 │  ├─  README.md
 │  ├─  requirements.txt
 │  └─  __init__.py
 ├─  app.py
 ├─  chatgpt_text_gen.py
 ├─  prompt.txt
 └─  secret.json

```

## ソースコード

　作成、および修正したソースコードはこちらです。chatgpt_text_gen.pyとsecret.jsonに関しては私の過去記事[PythonでちょっとChatGPT APIを使いたい時にちょうどいいclassを作った](https://qiita.com/miso_taku/items/5003a4cfc7eaa55df0f8)を参照してください。


```app.py
import subprocess
import os
import datetime

from flask import Flask, render_template, jsonify, request

from chatgpt_text_gen import ChatGPT

app = Flask(__name__)

chatgpt = ChatGPT()
p = None  # プロセスの初期化

def get_text(fn):
    texts = ""
    if os.path.exists(f"history/{fn}"):
        with open(f"history/{fn}", 'r') as fp:
            texts = "".join([i for i in fp])
    else:
        texts = '申し訳ありません。上手く聞き取れませんでした。'
    return texts

def start_process(unix_time):
    cmd = f"python whisper_mic/cli.py --time {unix_time}"
    p = subprocess.Popen(cmd.split())
    return p

@app.route('/')
def hello():
    return render_template('index.html')

@app.route('/start', methods=['POST'])
def start_recog():
    print("start")
    if not os.path.exists("history"):
        os.mkdir("history")
    unix_time =  round(datetime.datetime.now().timestamp())
    fn = f"recognized_{unix_time}"
    p = start_process(unix_time)
    return jsonify({'item': fn})

@app.route('/stop', methods=['POST'])
def stop():
    print("stop")
    global p
    item = ""
    try:
        if p:
            p.kill()
    except:
        item = "failed to stop the process"

    fn = request.json['rfn']
    item = get_text(fn)
    print(item) 
    if item == "申し訳ありません。上手く聞き取れませんでした。":
        return jsonify({'item': item})
    else:
        result = chatgpt.generate_from_file('prompt.txt', item)
    print(result)
    return jsonify({'item': result})

if __name__ == '__main__':
    app.run(debug=True)
```


```index.html
<!DOCTYPE html>
<html>
    <head>
        <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
        <style>
            body {
                background-color: #f8f9fa;
                padding: 20px;
            }
            input[type=text] {
                width: 80%;
                height: 20%%;
                padding: 12px 20px;
                margin: 8px 0;
                box-sizing: border-box;
            }
            .btn-primary {
                background-color: #007bff;
                border-color: #007bff;
            }
            .btn-primary:hover {
                background-color: #0069d9;
                border-color: #0062cc;
            }
            .btn-danger {
                background-color: #dc3545;
                border-color: #dc3545;
            }
            .btn-danger:hover {
                background-color: #c82333;
                border-color: #bd2130;
            }
            .btn-info {
                background-color: #17a2b8;
                border-color: #17a2b8;
            }
            .btn-info:hover {
                background-color: #138496;
                border-color: #117a8b;
            }
            div {
                margin-bottom: 10px;
            }
        </style>
    <body>
        <div>
            <input type="text" id="txt1">
        </div>
        <div>
            <button id="btn1" class = "btn-primary">注文開始</button>
            <button id="btn2" class = "btn-danger">注文終了</button>
        </div>
        <script>
            var timer = {}
            $("#btn1").on("click", function(){
                $.ajax({
                    url: '/start',
                    type: 'POST',
                    dataType: 'json',
                }).done(
                    function(json){
                        fn = json['item'];
                    }
                ).fail(
                    function(e){
                        console.log(e);
                        console.log('failed');
                    }
                )            
            });
            $("#btn2").on("click", function(){
                $("#txt1").val("");
                $.ajax({
                    url: "/stop",
                    type: "POST",
                    dataType: "json",
                    contentType: "application/json",
                    data: JSON.stringify({ 'rfn' : fn}),
                    dataType: 'json',
                }).done(
                    function(json){
                        $("#txt1").val(json["item"]);
                    }
                ).fail(
                    function(e){
                        console.log(e);
                        console.log("error");
                    }
                )
            });
        </script>
    </body>
</html>
```

```prompt.txt
# 指示書
・あなたは松屋の店員です。
・私とのやり取りは下記の作業手順に従って下さい。
・あなたの役割は注文を取ることなので、それ以外のことを聞かれても絶対に答えないで下さい。
・注文は音声ファイルをもとに作成するので、誤字脱字や漢字変換ミスが含まれる可能性があるので、注文変換例のように適切に注文をメニューにあるものに修正してください。

# 作業手順
1.私が注文をします。
2.私の注文内容に対して'〇〇×1、□□×2でよろしいでしょうか？合計金額は〇〇円です。'と返答して下さい。注文内容にメニューに無いものは無視して下さい。

# 注文変換例
| 変換前 | 変換後 |
| - | - |
| 牛飯 | 牛めし |
| とんじる | 豚汁 |
| 生卵 | 生玉子 |

# 例
入力：牛めしと生玉子
牛めし×1、生玉子×1でよろしいでしょうか？合計金額は480円です。


# メニュー
| 商品 | 値段 |
| - | - |
| 牛めし | 400円 |
| ネギたっぷり旨辛ネギたま牛めし | 550円 |
| 鬼おろしポン酢牛めし | 550円 |
| チーズ牛めし | 570円 |
| キムチーズ牛めし | 610円 |
| ネギとろろ牛めし | 610円 |
| ネギねぎ牛めし | 550円 |
| とろろ生玉子セット | 180円 |
| お新香生玉子セット | 150円 |
| キムチ生玉子セット | 150円 |
| ミニ牛皿生玉子セット | 220円 |
| 豚汁生玉子セット | 250円 |
| 生野菜生玉子セット | 180円 |
| とろろ半熟玉子セット | 180円 |
| お新香半熟玉子セット | 150円 |
| キムチ半熟玉子セット | 150円 |
| ミニ牛皿半熟玉子セット | 220円 |
| 豚汁半熟玉子セット | 250円 |
| 生野菜半熟玉子セット | 180円 |
| 松屋ビーフカレー | 680円 |
| チーズかけ松屋ビーフカレー | 860円 |
| ビーフカレギュウ | 880円 |
| チーズかけビーフカレギュウ | 1060円 |
| ハンバーグビーフカレー | 880円 |
| チーズかけハンバーグビーフカレー | 1060円 |
| 富士山豆腐の本格麻婆めし | 500円 |
| 富士山豆腐の本格麻婆コンボ牛めし | 630円 |
| キムカル丼 | 590円 |
| 牛焼ビビン丼 | 590円 |
| ホワイトソースハンバーグ定食 | 780円 |
| チーズホワイトソースハンバーグ定食 | 930円 |
| デミグラスハンバーグ定食 | 830円 |
| エッグデミグラスハンバーグ定食 | 880円 |
| チーズデミグラスハンバーグ定食 | 980円 |
| 牛めし定食 | 590円 |
| ネギたっぷり旨辛ネギたま牛めし定食 | 740円 |
| 鬼おろしポン酢牛めし定食 | 740円 |
| チーズ牛めし定食 | 760円 |
| キムチーズ牛めし定食 | 800円 |
| ネギとろろ牛めし定食 | 800円 |
| ネギねぎ牛めし定食 | 740円 |
| 牛バラ焼定食 | 990円 |
| 牛焼肉定食 | 780円 |
| カルビ焼肉定食 | 820円 |
| 豚焼肉定食 | 740円 |
| 富士山豆腐の本格麻婆定食 | 590円 |
| 豚汁 | 190円 |
| 生玉子 | 80円 |
| 半熟玉子 | 80円 |
| 生野菜 | 130円 |
| 牛皿並盛 | 320円 |
| ミニ牛皿 | 170円 |
| 富士山キムチ | 100円 |
| お新香 | 90円 |
| 納豆 | 100円 |
| 国産とろろ | 150円 |
| みそ汁 | 60円 |
| ライス並盛 | 160円 |
| ソーセージ半熟玉子 | 150円 |
| ソーセージエッグ | 150円 |
| 冷奴 | 100円 |
| ポテサラ | 70円 |
| ポテサラ生野菜 | 200円 |
| 松屋ビーフカレーソースミニ | 350円 |
| たっぷりチーズ | 180円 |
| 鬼おろし | 150円 |
| 焼鮭 | 300円 |
| 青ネギ | 130円 |
| ネギたま | 160円 |
| 焼きのり | 90円 |
| ネギダレ | 100円 |

# 注文

```

Whisper_micのcli.pyは一部変更。(変更点はコメント追加箇所# added、削除箇所# original)

```cli.py
import io
from pydub import AudioSegment
import speech_recognition as sr
import whisper
import queue
import tempfile
import os
import threading
import click
import torch
import numpy as np
import datetime # added

unix_time = round(datetime.datetime.now().timestamp())# added

@click.command()
@click.option("--model", default="base", help="Model to use", type=click.Choice(["tiny","base", "small","medium","large"]))
@click.option("--device", default=("cuda" if torch.cuda.is_available() else "cpu"), help="Device to use", type=click.Choice(["cpu","cuda"]))
@click.option("--english", default=False, help="Whether to use English model",is_flag=True, type=bool)
@click.option("--verbose", default=False, help="Whether to print verbose output", is_flag=True,type=bool)
@click.option("--energy", default=300, help="Energy level for mic to detect", type=int)
@click.option("--dynamic_energy", default=False,is_flag=True, help="Flag to enable dynamic energy", type=bool)
@click.option("--pause", default=0.8, help="Pause time before entry ends", type=float)
@click.option("--save_file",default=False, help="Flag to save file", is_flag=True,type=bool)
@click.option("--time", default=unix_time, help="unix time", type=int) # added
# def main(model, english,verbose, energy, pause,dynamic_energy,save_file,device): # original
def main(model, english,verbose, energy, pause, dynamic_energy, save_file,device, time): # added
    temp_dir = tempfile.mkdtemp() if save_file else None
    #there are no english models for large
    if model != "large" and english:
        model = model + ".en"
    audio_model = whisper.load_model(model).to(device)
    audio_queue = queue.Queue()
    result_queue = queue.Queue()
    threading.Thread(target=record_audio,
                     args=(audio_queue, energy, pause, dynamic_energy, save_file, temp_dir)).start()
    threading.Thread(target=transcribe_forever,
                     args=(audio_queue, result_queue, audio_model, english, verbose, save_file)).start()

    while True:
        # print(result_queue.get()) # original
        text = result_queue.get() # added
        file_name = f"history/recognized_{time}" # added
        fp = open(file_name, "a") # added
        fp.write(text) # added
        fp.close() # added
def record_audio(audio_queue, energy, pause, dynamic_energy, save_file, temp_dir):
    #load the speech recognizer and set the initial energy threshold and pause threshold
    r = sr.Recognizer()
    r.energy_threshold = energy
    r.pause_threshold = pause
    r.dynamic_energy_threshold = dynamic_energy

    with sr.Microphone(sample_rate=16000) as source:
        # print("Say something!") # original
        i = 0
        while True:
            #get and save audio to wav file
            audio = r.listen(source)
            if save_file:
                data = io.BytesIO(audio.get_wav_data())
                audio_clip = AudioSegment.from_file(data)
                filename = os.path.join(temp_dir, f"temp{i}.wav")
                audio_clip.export(filename, format="wav")
                audio_data = filename
            else:
                torch_audio = torch.from_numpy(np.frombuffer(audio.get_raw_data(), np.int16).flatten().astype(np.float32) / 32768.0)
                audio_data = torch_audio

            audio_queue.put_nowait(audio_data)
            i += 1


def transcribe_forever(audio_queue, result_queue, audio_model, english, verbose, save_file):
    while True:
        audio_data = audio_queue.get()
        if english:
            result = audio_model.transcribe(audio_data,language='english')
        else:
            # result = audio_model.transcribe(audio_data) # original
            result = audio_model.transcribe(audio_data,language='japanese') # added

        if not verbose:
            predicted_text = result["text"]
            # result_queue.put_nowait("You said: " + predicted_text) # original
            result_queue.put_nowait(predicted_text) # added
        else:
            result_queue.put_nowait(result)

        if save_file:
            os.remove(audio_data)

if __name__ == "__main__":
    main()
```

## 実行

・ターミナルで以下を実行

```
python app.py
```

・画面表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/350321/2bff6f42-2969-86e1-8525-760c28fbb13c.png)

・注文開始ボタンをクリックしてマイクに「牛めしと生玉子」と喋って、注文終了ボタンをクリック

・結果表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/350321/24f21ea7-4b1c-95ea-3bf1-b54baf3eafe6.png)


## 苦労したこと
　私の普段使いのノートPCではWhisper_micの音声からのテキスト変換がなかなか上手くいかず、苦労しました。(※私の滑舌の問題かもしれませんが…)

実際にあった例としては
入力音声：「牛めしと生玉子」
出力テキスト：「牛飯と生卵」
　　　　　　　「ギュウメッシュと生卵」
　　　　　　　「ギューメシト生卵」
　　　　　　　「牛飯と生田マゴ」

　多少、プロンプトも工夫してみましたが、まだまだ検討の余地がありそうです。

　上記に載せたWhisper_micの[こちらの記事](https://www.kkaneko.jp/ai/repot/micrecog.html)にもあるようにマシンパワーに余裕があればWhisper_micの起動時に--model mediumや--model largeを指定してもいいかもしれません。


## 今後の展開
・本当は手順で「注文」→「注文確認」→「合計金額表示」みたいに作りたかったのですが、サクッと作りたかったので、諦めました。
・最後の「注文内容＆合計金額」も音声＋画像表示出来るとよりよいUXになると思いましたが、サクッと作りたかったので、諦めました。（全て音声でやり取りできると、視覚障害者の方でも使えるので良いですよね。）
・メニューの数が多くなるとどうしても一回のトークン数が多くなって料金がかさんでくるのでなんとかしたいですね。
・Pineconeみたいなベクトルデータベースを使えばメニューの表記ゆれにも対応できたりするのかな？

## 参考にしたUdemy講座

[ChatGPTとWhisperではじめるPythonローコード開発入門
](https://www.udemy.com/course/chatgpt_whisper/learn/lecture/37214920?start=540#overview)

## 最後に

　もしかしたら、松屋の中の人が見ているかもしれないので、最後にこれだけは言っておきたい。

　**ごろごろチキンのバターチキンカレーの復活よろしくお願いします。**