React Native(Expo)とDjango REST frameworkで音声認識、タイトル自動生成アプリを作ってみた(後半)

## はじめに
この記事は[こちら]()の記事の後半です。前半では、Google Cloud Speech-to-Text APIを用いてスマホアプリで録音した音声を文章に変換するところまで実装しました。今回はその認識した文章に自動でタイトルをつけるAPIを作成し、スマホアプリで表示します。それではフロントエンドから実装を行っていきます。

## フロントエンド
今回修正する箇所は少ないです。以下のようにApp.jsに追加処理を書きます。
```react:App.jsに追記(変数の初期化)
export default function App() {
  //オーディオの再生に必要な変数
  const AudioRecorder = useRef(new Audio.Recording());
  const AudioPlayer = useRef(new Audio.Sound());

  //変数の初期化
  const [RecordedURI, SetRecordedURI] = useState('');
  const [AudioPermission, SetAudioPermission] = useState(false);
  const [IsRecording, SetIsRecording] = useState(false);
  const [IsPLaying, SetIsPLaying] = useState(false);
  const [resultText, setResultText] = useState('');
  const [summary, setSummary] = useState(''); //追加
...

```
ここでは自動で生成するタイトルを格納する変数であるsummaryを初期化しています。

次にStopRecordingメソッドに以下の内容を追記します。
```react:App.jsに追記(StopRecording)
//録音の停止をする関数
  const StopRecording = async () => {
    try {
      
      ...
      /////////////ここから下を追記/////////////
      url = `http://localhost:8000/api/summary`; // POST先のURL
      if(text === ""){
        setSummary("音声が認識できませんでした。");
        SetIsRecording(false);
        return;
      }
      //文章要約を行うAPIにPOST送信
      let response_summary = await client.post(url, {
        body: JSON.stringify({"data": text}),
      });
      //文章要約の結果を取得
      const json_summary = await response_summary.json();
      const parsed_json_summary = JSON.parse(json_summary);
      const summary = parsed_json_summary.result;
      setSummary(summary);
      ////////////////ここまで////////////////

      SetIsRecording(false);
    } catch (error) {
      console.log(error);
      SetIsRecording(false);
    }
  };
```
ここでは、バックエンドで作成する推論APIに音声認識結果である文章をPOSTし、その結果をsummary変数に格納しています。音声認識において、音声を認識出来なかった場合には、summary変数には「音声が認識できませんでした。」という文字列が格納されます。

次にJSXに以下のように追記します。
```react:App.jsの追記(JSX)
return (
        ...

        <Text style={styles.text}>再生</Text>
        <Ionicons style={styles.icon} name="play-circle-sharp" size={50} color="black" onPress={PlayRecordedAudio}/></>
      )}
      </View>
       {/* ここから */}
      {summary &&
        <>
        <Text style={styles.result_title}>要約</Text>
        <View style={styles.result_container}>
        <Text  style={styles.result_text}>{summary}</Text>
        </View>
        </>
      }
      {/* ここまで */}
      {resultText &&
        <>
        <Text style={styles.result_title}>結果</Text>
        <View style={styles.result_container}>
        <Text  style={styles.result_text}>{resultText}</Text>
        </View>
        </>
      }
    </ScrollView>
  );
}
```
これにより、summary変数に値が入っているときに自動生成されたタイトルが表示されるようになります。以上でフロントエンドの追記は終わりです。次はバックエンドです。

#### バックエンドの実装(後半)
ここでは前半で行った音声認識によって得た文章に自動でタイトルを作成する推論APIを作成します。この推論を行うモデルはHugging Faceという自然言語処理、特にTransformerに力を入れて研究、開発を行っているアメリカの会社があるのですが、その中でもTransformerというライブラリを用いて、学習済みモデルで推論を行います。この記事は慶應理工アドベントカレンダーの記事ですが、[8日目の記事](https://qiita.com/fummicc1_dev/items/1d86ee262709ecd4ae30)でもHugging Faceの説明やアプリ実装例について書いてあるので、参考になると思います。
今回のタイトル自動生成に使うモデルは[こちら](https://huggingface.co/sonoisa/t5-qiita-title-generation/tree/main)のモデルであり、ここのconfig.json、model.pth、pytorch_model.bin、special_tokens_map.json、spiece.model、tokenizer_config.jsonの全てをダウンロードします。そしてこれらを`api`フォルダに新しく`ml_models`フォルダを作成し、その配下に全て移動させてください。

ここからバックエンドの処理の追記をしていきます。まずはurls.pyです。
```python:urls.pyの追記
from django.urls import path, include
from rest_framework import routers
from .views import AudioFileView,TextView
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('speech2text/<str:filename>', AudioFileView.as_view(), name='speech2text'),
    path('summary', TextView.as_view(), name='summary'),#追記
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```
ここでは、音声認識APIエンドポイントに加え、タイトル自動生成推論APIのエンドポイントを追加します。これにより、`localhost:800/api/summary`に文章をPOSTすることでタイトルを生成して返してくれるようになります。(します。)

次はmodels.pyです。以下のようにAudioModelの下にまるごと追記します。
```python:models.pyの追記
CODE_PATTERN = re.compile(r"```.*?```", re.MULTILINE | re.DOTALL)
LINK_PATTERN = re.compile(r"!?\[([^\]\)]+)\]\([^\)]+\)")
IMG_PATTERN = re.compile(r"<img[^>]*>")
URL_PATTERN = re.compile(r"(http|ftp)s?://[^\s]+")
NEWLINES_PATTERN = re.compile(r"(\s*\n\s*)+")

class TextModel(models.Model):
    text = models.TextField()

    MODEL_PATH = 'api/ml_models/'
    tokenizer = T5Tokenizer.from_pretrained(MODEL_PATH, is_fast=True)
    trained_model = T5ForConditionalGeneration.from_pretrained(MODEL_PATH)

    def predict(self):
        USE_GPU = torch.cuda.is_available()
        if USE_GPU:
            self.trained_model.cuda()

        self.preprocess_questionnaire_body(self.text)
        MAX_SOURCE_LENGTH = 512   # 入力文の最大トークン数
        MAX_TARGET_LENGTH = 126   # 生成文の最大トークン数

        # モデルを推論モードに設定
        self.trained_model.eval()

        # 入力文の前処理を行う
        inputs = [self.preprocess_questionnaire_body(self.text)]
        batch = self.tokenizer.batch_encode_plus(
            inputs, max_length=MAX_SOURCE_LENGTH, truncation=True,
            padding="longest", return_tensors="pt")

        input_ids = batch['input_ids']
        input_mask = batch['attention_mask']
        if USE_GPU:
            input_ids = input_ids.cuda()
            input_mask = input_mask.cuda()

        # モデルに入力し、生成文を取得
        outputs = self.trained_model.generate(
            input_ids=input_ids, attention_mask=input_mask,
            max_length=MAX_TARGET_LENGTH,
            return_dict_in_generate=True, output_scores=True,
            temperature=1.0,            # 生成にランダム性を入れる温度パラメータ
            num_beams=10,               # ビームサーチの探索幅
            diversity_penalty=1.0,      # 生成結果の多様性を生み出すためのペナルティ
            num_beam_groups=10,         # ビームサーチのグループ数
            num_return_sequences=5,    # 生成する文の数
            repetition_penalty=1.5,     # 同じ文の繰り返し（モード崩壊）へのペナルティ
        )

        # 生成された文をデコードする
        generated_sentences = [
            self.tokenizer.decode(
                ids,
                skip_special_tokens=True,
                clean_up_tokenization_spaces=False) for ids in outputs.sequences]

        # 生成された文を表示する
        summary = []
        for i, sentence in enumerate(generated_sentences):
            if i == 0:
                summary.append(self.preprocess_questionnaire_body(sentence))
        return self.text, summary[0][12:]

    # ユニコード正規化
    def unicode_normalize(self, cls, s):
        pt = re.compile('([{}]+)'.format(cls))

        def norm(c):
            return unicodedata.normalize('NFKC', c) if pt.match(c) else c

        s = ''.join(norm(x) for x in re.split(pt, s))
        s = re.sub('-', '-', s)
        return s

    # 余分な空白を削除する処理
    def remove_extra_spaces(self, s):
        s = re.sub('[  ]+', ' ', s)
        blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                          '\u3040-\u309F',  # HIRAGANA
                          '\u30A0-\u30FF',  # KATAKANA
                          '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                          '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                          ))
        basic_latin = '\u0000-\u007F'

        def remove_space_between(cls1, cls2, s):
            p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
            while p.search(s):
                s = p.sub(r'\1\2', s)
            return s

        s = remove_space_between(blocks, blocks, s)
        s = remove_space_between(blocks, basic_latin, s)
        s = remove_space_between(basic_latin, blocks, s)
        return s

    # NEologdを用いて正規化する処理
    def normalize_neologd(self, s):
        s = s.strip()
        s = self.unicode_normalize('0-9A-Za-ｚ｡-ﾟ', s)

        def maketrans(f, t):
            return {ord(x): ord(y) for x, y in zip(f, t)}

        s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s)  # normalize hyphens
        s = re.sub('[﹣－ｰ—―─━ー]+', 'ー', s)  # normalize choonpus
        # normalize tildes (modified by Isao Sonobe)
        s = re.sub('[~∼∾〜〰～]+', '〜', s)
        s = s.translate(
            maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~｡､･｢｣',
                      '！”＃＄％＆’（）＊＋，－．／：；＜＝＞？＠［￥］＾＿｀｛｜｝〜。、・「」'))

        s = self.remove_extra_spaces(s)
        s = self.unicode_normalize(
            '！”＃＄％＆’（）＊＋，－．／：；＜＞？＠［￥］＾＿｀｛｜｝〜', s)  # keep ＝,・,「,」
        s = re.sub('[’]', '\'', s)
        s = re.sub('[”]', '"', s)
        return s

    # 前処理
    def preprocess_questionnaire_body(self, markdown_text):
        markdown_text = CODE_PATTERN.sub(r"", markdown_text)
        markdown_text = LINK_PATTERN.sub(r"\1", markdown_text)
        markdown_text = IMG_PATTERN.sub(r"", markdown_text)
        markdown_text = URL_PATTERN.sub(r"", markdown_text)
        markdown_text = NEWLINES_PATTERN.sub(r"\n", markdown_text)
        markdown_text = markdown_text.replace("`", "")
        markdown_text = markdown_text.replace("\t", " ")
        markdown_text = self.normalize_neologd(markdown_text).lower()
        markdown_text = markdown_text.replace("\n", " ")
        markdown_text = markdown_text[:4000]
        return "body: " + markdown_text
```
ここではpredictメソッドに推論の処理を書き、推論結果を返すようにしています。この推論パートのコードは[こちらを](https://qiita.com/sonoisa/items/30876467ad5a8a81821f)参考にしました。(時間ができたら自作のモデルによる推論を行いたい。)
次にserializers.pyの追記を行います。
```python:serializers.pyの追記
...
class TextSerializer(serializers.Serializer):
    """テキストを受け取るためのシリアライザ"""
    text = serializers.CharField(max_length=1000)

    class Meta:
        model = TextModel
        fields = ('text',)

    def is_valid(self, *, raise_exception=False):
        return super().is_valid(raise_exception=raise_exception)

    def create(self,validated_data):
        return TextModel.objects.create(**validated_data)
```
文章を受け取るだけのとてもシンプルなシリアライザです。

最後にviews.pyの追記を行います。
```python:views.pyの追記
...
class TextView(generics.GenericAPIView):
    serializer_class = TextSerializer

    def post(self, request, *args, **kwargs):
        #データを受け取る
        serializer = TextSerializer(data={'text':request.data['data']})
        #データが正しいか確認する
        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        model = serializer.save()
        #文章要約を行う
        _, summary = model.predict()
        return Response(json.dumps({"result":summary}), status=status.HTTP_201_CREATED)
```
とても処理は単純であり、シリアライザのインスタンスを作成し、データが正しければモデルを保存しタイトルの自動生成を行います。これで全ての追記が終わったので前回と同様な手順で、expo startとpython manage.py runserverをすると、動作確認できます。正しければ、音声認識結果が表示された少し後に自動生成されたタイトルも表示されると思います。

## おわりに
最後まで読んで頂き本当にありがとうございます。軽い記事にするつもりでしたが、長くなってしまいました。また、簡単な構成を心がけましたが、全くReactやDjangoに触れたことがない方にとっては難しい内容になってしまったと思います。申し訳ありません....。今回はAPIや学習済みモデルに頼ってしまいましたが、これからはより本格的にアプリに機械学習、深層学習の自作モデルを組み込んでいきたいと考えています。コードはgithubにあげています。フロントエンドの実装は[こちらから](https://github.com/sh237/SaveSpeechAsTextFront)、バックエンドの実装は[こちらから](https://github.com/sh237/SaveSpeechAsTextBack)

#### 参考
- 【人工知能】深層学習で「記事タイトルを自動生成」する
https://qiita.com/sonoisa/items/30876467ad5a8a81821f

- 【入門編】React Nativeとは？メリット・デメリットからHello, Worldまで https://udemy.benesse.co.jp/development/app/react-native.html

- npmをインストール｜Windows、Mac、Linux｜OS別
https://itc.tokyo/linux/install-npm/

- 事前学習済みモデルで感情識別をしてみた
https://qiita.com/fummicc1_dev/items/1d86ee262709ecd4ae30